# confval > A batteries-included configuration toolkit for Rust This file contains all documentation content in a single document following the llmstxt.org standard. ## Agent Skills Setting up a confval pipeline is mechanical and specific to your domain model. You write a spec type whose fields carry a span, validation that accumulates into a report, and lowering into runtime types. The shape of each depends on the settings you parse. confval ships two agent skills that walk an agent through that work, and a `confval` binary whose job is to install them into your project. The binary parses no configuration and validates nothing. It writes the skill files and reports what it wrote. ## Installing the binary The binary lives in the `confval` package, so the crate you already depend on installs it. ```shell cargo install confval ``` `confval init` then writes the skills into your project. ## The two skills The skills answer two different questions. `confval-init` scaffolds a pipeline in a project that does not have one. It surveys the project, reads the configuration format, adds the dependency, and writes the spec, validation, and runtime layers, stopping at the boundary where your domain rules begin. `confval-add-block` keeps the layers in sync when you add a field or block to a project that already has a pipeline. A new setting runs through the spec type, the validation, the runtime type, the lowering, and the `Default` impl. The skill updates all five layers for the setting it adds. The skills are written to disk rather than injected into one session, because `confval-add-block` is a maintenance procedure you need long after anyone ran `confval init`. ## Running confval init ```shell confval init ``` The default writes both skills into the project, prints one line per file, and exits. Project scope is the default, because the skills describe one project's configuration layer. A project skill can be committed, so everyone on the repository has it. The binary installs into the repository root, which it finds by walking up from the working directory to the nearest ancestor that holds a `.git` entry. The walk is what makes the command usable from anywhere in a repository. The report names the absolute directory it chose, so the walk is visible rather than silent. ### Where the files land The agent selects the directory segment and the scope selects the base. `--agent claude` selects `.claude`, the only segment this release writes. | Scope | Base | Path written | |-------------------------|---------------------|------------------------------------------| | `project` (default) | the repository root | `/.claude/skills//SKILL.md` | | `user` (`--scope user`) | your home directory | `/.claude/skills//SKILL.md` | A reference file lands beside its `SKILL.md` at its relative path. ### Invoking a skill For a project or personal skill, the invocation comes from the directory name. After `confval init`, run `claude` in the project and invoke `/confval-init` or `/confval-add-block`. Pass `--launch` to `confval init` to open a primed session for you. ```shell confval init --launch ``` ### Listing the skills `confval init --list` prints each skill and its description and writes nothing. ## Outcomes and exit codes Each file gets one outcome, decided by comparing the bytes on disk with the bytes the binary would write. | Situation | Outcome | |-----------------------------------------|-------------| | no file at the path | `created` | | the file matches what the binary writes | `unchanged` | | the file differs, without `--force` | `skipped` | | the file differs, with `--force` | `updated` | The binary writes its own version into the skill text. An upgraded binary therefore reports an untouched older file as differing. That is the drift signal, and `--force` is how you take the newer text. The report describes the file as differing from the copy the binary ships rather than as edited, because an older binary's output differs for the same reason. | Code | Meaning | |------|-------------------------------------------------------------------------------| | 0 | every file is present and current, and the agent exited 0 if one was launched | | 1 | at least one file was skipped | | 2 | a usage error, including no subcommand, an unknown flag, agent, or scope | | 3 | an IO error, a home directory that could not be determined, or an agent that could not be launched | | 4 | the agent ran and exited non-zero | ## What you can observe A first run reports each file `created`. A second run reports `unchanged` and leaves the bytes alone. A file you edited reports as differing from the copy the binary ships, and it is left alone until you pass `--force`. An upgraded binary reports the same way for a file it did not write. Nothing is deleted. A reference file a later release stops shipping stays on disk until you remove it. A file in a skill directory that the binary does not ship stays in place, and the report does not name it. --- ## Contributing ## Just recipes Various just recipes are available, but these are the most useful: - `just docs`: run the docs site locally - `just validate`: test everything, including lint and unit tests - `just mutants`: run mutation testing to find gaps the suite does not cover `just validate` is the gate a change has to pass. `just mutants` runs longer and is worth running when you add a module or change how one behaves. It builds the whole workspace once per mutant, so expect it to take a while. `confval-derive` has no tests of its own, which is why `.cargo/mutants.toml` sets `test_workspace`. Its behavior is covered by the integration and trybuild tests in the `confval` package. ## Design confval is built around five design decisions. A change that departs from one of these decisions needs a reason in the pull request. ### Spans travel with values - Every parsed value carries the byte range it came from. - Any later check resolves that range to a line and column in the source file. ### All errors are collected and displayed to an operator - Parsing and validation append to a shared report instead of returning on the first error. - The caller fixes one batch of problems rather than rerunning to find the next one. ### Validation happens in stages - Parsing checks shape only, meaning the field exists and has the right type. - Range checks, closed sets, and cross-field rules run after parsing, in plain validation functions. ### The core does not know any file format - Parsing produces a format-neutral field model. - A frontend converts one syntax into that model. - HCL, TOML, KDL, JSON, and YAML ship today, each behind its own feature. A new format is another frontend over the same model. ### The core has no required dependencies serde, annotate-snippets, hcl-edit, toml_edit, kdl, jsonc-parser, saphyr-parser, and the derive macros are each behind a feature flag. confval aims to stay free of required dependencies. Put any new dependency behind a feature flag. ## Examples The [Examples](./examples.md) page lists each example's run command. If you add an example or change one's required features, update that page and the `examples` recipe to match. `just examples` prints every example's output for review. ## Crate layout confval is organized into four layers, each a module, plus a prelude. The dependency direction is strictly downward. `format` builds on `pipeline`, which builds on `diagnostic`, which builds on `source`. | Module | Holds | |-----------------------|---------------------------------------------------------------------------------------------| | `confval::source` | `Located`, `Span`, `SourceId`, `Source`, `SourceMap` (the "where") | | `confval::diagnostic` | `Report`, `Issue`, `IssueBuilder`, `Severity`, the renderers (the "what") | | `confval::pipeline` | `Lower`, `LowerAuto`, `Validate`, `narrow`, `RangeConstraint`, `KeywordSet` (the transform) | | `confval::format` | the neutral field model (`field`) and the frontends (`hcl`, `json`, `kdl`, `toml`, `yaml`) | | `confval::prelude` | a glob re-export of the common imports across those layers | `use confval::prelude::*;` pulls the everyday names (`Located`, `Span`, `Report`, `Lower`, `Validate`, `narrow`, `RangeConstraint`, `KeywordSet`, and the derives) in one line. --- ## Examples Fifteen runnable examples ship in the repository: fourteen in `crates/confval/examples/` and a runnable language server in `crates/confval-lsp/examples/`. `hcl`, `toml`, `kdl`, `json`, and `yaml` are the same program five times. Each renders the diagnostics for a failing variant to stderr, feeds a valid document, prints the lowered config, and emits the populated spec back to canonical text. They differ in the source text, its file name, and the two format calls that parse and emit it. The rest demonstrate one feature each, except `handwritten`, which runs the whole pipeline over a spec written without the derive. Each section below gives the run command. `just examples` runs them all. ## hcl The `hcl` example runs those steps over HCL. ```shell cargo run -q -p confval --example hcl --features derive,color,hcl ``` ## toml The `toml` example runs those steps over TOML. ```shell cargo run -q -p confval --example toml --features derive,color,toml ``` ## kdl The `kdl` example runs those steps over KDL. ```shell cargo run -q -p confval --example kdl --features derive,color,kdl ``` ## json The `json` example runs those steps over JSON. ```shell cargo run -q -p confval --example json --features derive,color,json ``` ## yaml The `yaml` example runs those steps over YAML. ```shell cargo run -q -p confval --example yaml --features derive,color,yaml ``` ## issue_severity The `issue_severity` example illustrates the difference between an error and a warning. ```shell cargo run -q -p confval --example issue_severity --features derive,color,toml ``` ## validate_traversal The `validate_traversal` example shows what `validate_all` reaches. ```shell cargo run -q -p confval --example validate_traversal --features derive,color,toml ``` ## layering The `layering` example assembles one config from a base file, a joined defaults file, the environment, and the command line. ```shell cargo run -q -p confval --example layering --features derive,color,toml,layering ``` See [Layering](./guide/layering.md) for how the sources merge and how environment and command line values are coerced. ## templates The `templates` example renders a spec back to configuration text. ```shell cargo run -q -p confval --example templates --features derive,color,toml,hcl ``` The spec populates with its defaults and emits twice per format, once plain and once as a template with each field's doc comment above it. The unset optional `pid_file` stays out of the plain form and renders in the template as a commented-out entry. See [Templates](./guide/templates.md) for how `to_fields`, `to_template`, and the emitters fit together. ## doc_fallback The `doc_fallback` example shows where a template block's comment comes from. ```shell cargo run -q -p confval --example doc_fallback --features derive,toml ``` ## json_diagnostics The `json_diagnostics` example renders a report as JSON for CI and tooling. ```shell cargo run -q -p confval --example json_diagnostics --features derive,serde,toml ``` ## narrow The `narrow` example shows the ready-made narrowing helpers that convert spec integers to the widths a runtime type needs. It exercises five of them. The remaining integer widths and their `opt_` variants share the same shape. ```shell cargo run -q -p confval --example narrow --features derive,color,toml ``` ## representations The `representations` example prints the three views of one loaded spec: the source view of what was set, the populated view after defaults, and the runtime view of the lowered values. ```shell cargo run -q -p confval --example representations --features derive,serde,toml ``` ## handwritten The handwritten example writes a spec without the derive, for a block whose `mode` field decides which fields the rest of the block has. Each level of its tree is written the other way from the level above: the root is handwritten, its children are derived, and the `tls` block inside a derived route is handwritten again. It prints the diagnostics, the runtime config, the populated and source views, the comments a handwritten node drops from a template, and the same model in HCL. ```shell cargo run -q -p confval --example handwritten --features derive,color,toml,hcl ``` ## serve The serve example runs the language server over stdio against a demo spec, so you can point an editor at a running server before writing your own. Pick a format, then launch an LSP client at the built binary. [Language Server](./guide/language-server.md#trying-it-against-an-editor) walks through an editor setup. ```shell cargo run -p confval-lsp --example serve hcl ``` ## More examples - An example PR for [mini-redis](https://github.com/ethanhann/mini-redis/pull/1). - Snakeway reverse proxy's [snakeway-conf crate](https://github.com/snakewayhq/snakeway/tree/main/crates/snakeway-conf/src) (advanced usage) --- ## Getting Started confval is a Rust crate for parsing, validating, and lowering configuration files. It records a source span for every parsed value, so a validation error can report the line and column in the file the value came from. Use it to build the configuration layer of an application. You define the shape of the config as Rust types, parse a file into those types, run validation, and lower the result into the runtime types the rest of the program uses. ## Installation Add confval to your `Cargo.toml`. The crate has no default features. Enable the format frontends and extras you use. For example, for TOML format, derive macros, JSON diagnostics, and console color support: ```shell cargo add confval --features "toml,derive,serde,color" ``` Or, the HCL format, derive macros, and plain output: ```shell cargo add confval --features "hcl,derive" ``` :::info confval also ships two agent skills. One scaffolds a pipeline in a project that has none, and the other keeps the layers in sync when you add a setting. Install the binary with `cargo install confval`, then run `confval init` to write the skills into your project. See [Agent Skills](./agent-skills.md) for the full workflow. ::: ### Feature flags | Flag | Default | Brings in | Enables | |------------|---------|---------------------|------------------------------------------------------------------------------------------| | `serde` | off | `serde` | `Located` serde impls, `render_json` | | `color` | off | `annotate-snippets` | `render_pretty` with ANSI color | | `hcl` | off | `hcl-edit` | The `confval::format::hcl` frontend | | `toml` | off | `toml_edit` | The `confval::format::toml` frontend | | `kdl` | off | `kdl` | The `confval::format::kdl` frontend | | `json` | off | `jsonc-parser` | The `confval::format::json` frontend | | `yaml` | off | `saphyr-parser` | The `confval::format::yaml` frontend | | `derive` | off | `confval-derive` | `#[derive(Spec)]` and `#[derive(Config)]` (format-neutral) | | `layering` | off | nothing | The `confval::layering` module for assembling from a file, environment, and command line | Frontends (that define the configuration format) are independent opt-ins. Pick `hcl`, `toml`, `kdl`, `json`, or `yaml` for the format you want. The `derive` feature emits the format-neutral `FromFields`, so it brings in no parser on its own. The `layering` feature adds the [layering](./guide/layering.md) module, which merges several sources into one configuration. It pulls in no external crate. ## A complete example This example parses an HCL document, validates it, checks the report for errors, and lowers the validated spec into a runtime config. The crate ships the same program as multiple runnable examples. `hcl.rs`, `toml.rs`, `kdl.rs`, `json.rs`, and `yaml.rs` each supply a source document and the two format calls that parse and emit it. All five pull everything after parsing from a shared `common` module. The listing below is a trimmed version of that module and the `hcl` example's `main`. Read through it once for the overall shape. ```rust use confval::prelude::*; use std::collections::{BTreeMap, HashMap}; range_constraint!(PORT, i64, min: 1, max: 65535); range_constraint!(WORKERS, i64, min: 1, max: 512); range_constraint!(MAX_BODY_MB, i64, min: 1, max: 1024); keyword_enum!(pub LimitMode, { Enforce => "enforce", Log => "log", Off => "off", }); #[derive(confval::Spec)] struct ServerSpec { hostname: Located, #[confval(range = PORT)] port: Located, #[confval(default = 4, range = WORKERS)] workers: Located, #[confval(default = false)] tls: Located, // A list field. The bare `default` reads an absent list as empty. Each // element keeps its own span, so a bad entry is reported at that entry. #[confval(default)] allow: Vec>, // An open-ended, string-keyed map, for keys that are not known ahead of // time, such as header names. #[confval(map, default)] headers: BTreeMap>, // Optional in the source. When the block is omitted, the spec keeps it // `None`, so a spec dump stays source-faithful. The config side fills the // default at lowering time. #[confval(nested)] limits: Option>, } #[derive(confval::Spec)] #[confval(derive_default)] struct LimitsSpec { #[confval(default = 16, range = MAX_BODY_MB)] max_body_mb: Located, #[confval(default = "enforce".to_string(), keywords = LimitMode)] mode: Located, } impl Validate for LimitsSpec { // `max_body_mb` and `mode` record their constraints with `#[confval(range)]` // and `#[confval(keywords)]`, so the derive checks them. Every rule this // block has is recorded, so its `Validate` body is empty. fn validate(&self, _report: &mut Report) {} } impl Validate for ServerSpec { fn validate(&self, report: &mut Report) { // `port` and `workers` record their ranges, so the derive checks them. // This body holds only the rules an attribute cannot express. if self.hostname.value.is_empty() { report .error("hostname must not be empty") .at(self.hostname.span) .help("Set hostname to a reachable address, e.g. \"127.0.0.1\".") .emit(); } if self.hostname.value == "0.0.0.0" { report .warning("hostname set to listen on every available network device") .at(self.hostname.span) .help("This might be undesired.") .emit(); } for entry in &self.allow { if entry.value.is_empty() { report .error("allow entries must not be empty") .at(entry.span) .help("Remove the entry or set it to a network, e.g. \"10.0.0.0/8\".") .emit(); } } } } #[derive(confval::Config)] #[confval(lower_from = ServerSpec)] struct ServerConfig { hostname: String, #[confval(lower(from = port, with = narrow::i64_to_u16))] port: u16, #[confval(lower(from = workers, with = narrow::i64_to_usize))] workers: usize, tls: bool, #[confval(lower(from = allow, with = allow_to_vec))] allow: Vec, // Auto-mapped from the spec's `BTreeMap>`. The // `LowerAuto` impl drops each value's span and hands back a plain runtime // map. headers: HashMap, // The spec field is `Option>`. With `default` an absent // block lowers `LimitsSpec::default()` instead of producing a missing-field // error, and the runtime field stays non-optional. #[confval(nested, default)] limits: LimitsConfig, } #[derive(confval::Config)] #[confval(lower_from = LimitsSpec)] struct LimitsConfig { #[confval(lower(from = max_body_mb, with = narrow::i64_to_u16))] max_body_mb: u16, #[confval(lower(from = mode, with = narrow::keyword::))] mode: LimitMode, } fn allow_to_vec(value: &[Located], _report: &mut Report) -> Option> { Some(value.iter().map(|entry| entry.value.clone()).collect()) } fn main() { let input = r#"hostname = "127.0.0.1" port = 8080 limits { mode = "log" } "#; let mut sources = SourceMap::new(); let mut report = Report::new(); let id = sources.add("server.hcl", input); let spec: Option = confval::format::hcl::parse_hcl(&sources, id, &mut report); if let Some(spec) = &spec { spec.validate_all(&mut report); } // Validation ran, so lower only when the spec parsed and the report is // clean. A syntax error left `spec` as None, and validation may have added // errors. let config = if report.has_errors() { None } else { spec.as_ref() .and_then(|spec| ServerConfig::lower(spec, &mut report)) }; let Some(config) = config else { // Render every problem the report collected, then stop. A bad // configuration file is reported, never a panic. let mut out = String::new(); let _ = report.render_pretty(&sources, &mut out); eprint!("{out}"); std::process::exit(1); }; println!( "listening on {}:{} with {} workers", config.hostname, config.port, config.workers ); println!( "limits: max_body_mb={} mode={}", config.limits.max_body_mb, config.limits.mode ); println!("tls: {}", config.tls); } ``` ## How the example fits together The program above has four parts. Each maps to one stage of the [pipeline](pipeline.md) and has its own guide page for the detail. - The spec types, `ServerSpec` and `LimitsSpec`, declare the fields you parse a file into. `#[confval(derive_default)]` on `LimitsSpec` derives its `Default` from the same attribute defaults that fill an omitted field. See [Parsing](./guide/parsing.md). - A mechanical constraint is recorded on its field with `#[confval(range = ...)]` or `#[confval(keywords = ...)]`, and the derive checks it during validation. The `Validate` impls hold the remaining rules and report at each field's span. See [Validation](./guide/validation.md). - The config types, `ServerConfig` and `LimitsConfig`, are the runtime form the validated spec lowers into. See [Lowering](./guide/lowering.md). - The `main` function runs the stages in order: parse, validate, check `has_errors`, then lower. It handles the parse and lower `Option` values rather than unwrapping them, so a bad file is reported and the program exits rather than panicking. See [Diagnostics](./guide/diagnostics.md) for how the report renders. To watch the report work, put some bad values in the input: an empty `hostname`, a `port` of `99999`, an unknown `mode`. The `has_errors` check stops the run before lowering. All three problems come back reported, each at its own line and column. ## Running the examples The program above ships as the `hcl`, `toml`, `kdl`, `json`, and `yaml` examples in `crates/confval/examples/`, alongside examples for warnings, validation traversal, layering, and templates. See [Examples](./examples.md) for each run command and the output it prints. --- ## Diagnostics When parsing or validation finds a problem, it does not throw. It records the problem in a `Report`. confval renders that report for people to read. A span lets each message point at the line and column in the file. ## Report and IssueBuilder `Report` collects issues. Validators receive `&mut Report` and emit through a builder: ```rust report .error("port must be between 1 and 65535") .at(spec.port.span) .help("Choose a port in the range 1..=65535.") .emit(); ``` - `report.error(msg)` and `report.warning(msg)` return an `IssueBuilder`. - `.at(span)` attaches the primary span. Issues without a span render without a source location. - `.help(text)` adds a suggestion line. - `.related(span, label)` attaches secondary spans, used for messages like "first declared here". - `.emit()` finalizes the issue. The builder is `#[must_use]`, so forgetting `.emit()` is a compile-time warning. Query methods: `has_errors()`, `has_warnings()`, `has_issues()`. Severity is the two-variant `Severity` enum (`Error`, `Warning`). ## Rendering Renderers write into any `fmt::Write` sink and take the `SourceMap` to resolve spans: | Method | Feature gate | Format | |-----------------|------------------|-------------------------------------------------| | `render_plain` | always available | One line per issue with `file:line:col`, for CI | | `render_pretty` | `color` | rustc-style output with source excerpts, via `annotate-snippets` | | `render_json` | `serde` | Structured JSON for tooling | ```rust let mut out = String::new(); report.render_pretty(&sources, &mut out)?; eprint!("{out}"); ``` Pretty output underlines the offending value in its source line: ``` error: unknown load_balancing_strategy: failovr ╭▸ ingress.d/api.hcl:12:29 │ 12 │ load_balancing_strategy = "failovr" │ ━━━━━━━━━ │ ╰ help: expected one of: failover, round_robin, request_pressure, sticky_hash, random ``` Line and column lookups are O(log n) via a per-source line index. Columns count characters, not bytes. An issue records only a severity, message, optional span, optional help, and related spans. It never reads source text until render time. ## Spans and source A span is a byte range inside one registered source: ```rust pub struct Span { pub source: SourceId, pub start: u32, pub end: u32, } ``` `SourceId` is a lightweight handle issued by the `SourceMap`. Spans are plain data. Resolving them to line and column numbers happens only at render time. The `SourceMap` interns source text. Each file (or in-memory string) is registered once and identified by its `SourceId`: ```rust let mut sources = SourceMap::new(); let id = sources.add("config.hcl", text); ``` Reports do not own source text. Renderers take `&SourceMap` so the text is stored exactly once no matter how many issues reference it. --- ## Editor Support confval rejects an unknown or invalid field when your program starts, so a mistake in a handwritten configuration stops the program instead of passing silently. Editor support moves that feedback to where you write the file. It shows which fields are legal, what each one holds, and where the file is wrong, before you run the program. You get this by opening your configuration in an editor connected to a confval language server. The [Language Server](./language-server.md) page covers how a developer runs one. This page describes what the editor does for you once it is running. ## Diagnostics The editor underlines the same errors your program would report. It runs the real validation rather than an approximation, so an error the editor shows is an error the program would raise. The checks include an unknown field, an out-of-range value, an undefined reference, a duplicate label, and an empty label. ## Completion Completion offers what is legal at the cursor. On a fresh line inside a block, it offers the field names and block types the schema allows there, and it hides a single-valued field you have already set. The list follows the schema's declaration order, so related fields stay together. On a value, it offers the values the schema allows. A field with a fixed set of keywords offers those keywords. A field that points at another block offers the labels defined in the scope the reference resolves in. These are the labels a reference can resolve to without an error. Completion keeps working while you type, before the file is valid. ## Defaults Accepting a field that has a default writes the default in for you. The value arrives selected, so one keystroke replaces it. For example, accepting `workers` writes `workers = 4` with the `4` selected. On a value position, the default is offered as a preselected item. ## Hover Hover on a field reads its documentation. It shows the field's doc comment, its type, its constraint, and whether it has a default. It also states whether the configuration sets the field or leaves it to the default. For example, hover on `workers` reads "Defaults to 4." A default the editor cannot print as a value, such as a list, states only that a default applies. Hover on a reference value names the block it points to and states whether the value matches a defined label. ## Quick fixes A value that has a default carries a quick fix. The fix sets the field to its default. For example, an out-of-range `workers = 9999` becomes `workers = 4` in one step. ## Navigation Navigation follows references and labels. Go-to-definition jumps from a reference value to the label it names. Find-references lists every reference to a label, whether you start from the label or from one of its references. The editor's outline and breadcrumbs show the block tree, with each block carrying its label. ## Formats The editor supports every format confval parses: HCL, TOML, KDL, JSON, and YAML. For HCL, TOML, KDL, and JSON, completion and navigation stay precise while you type, even before the file parses. YAML reads structure from indentation, so it handles the common shapes, including a block sequence, a value on the next line, and an inline flow collection. An unusual YAML layout, such as a flow collection spread across several lines, can resolve less precisely than a block mapping. ## One open file at a time The editor checks the file you have open on its own. When you assemble a configuration from several [layers](./layering.md), a later layer can supply a value the open file leaves out. The editor does not see the other layers, so it can report a required field as missing even though a layer supplies it. In a layered setup, treat a missing-field error as a prompt to check the other layers rather than a fault in the open file. --- ## Format Limitations Sometimes you parse a configuration in one format and emit it in another, or you generate a template and wonder whether the write can fail. The formats do not share one vocabulary. TOML has a datetime literal and JSON does not. KDL, TOML, and YAML write infinity where JSON and HCL have no token for it. This page lists the gaps format by format, so you can see which conversions fail before you run one. A value that cannot be expressed produces an error naming the value and its dotted path. This holds in every format. Nothing is rounded, approximated, or silently dropped. ## Values outside the model Every frontend parses into the same neutral field model, and the model's scalars are strings, `i64` integers, `f64` floats, and booleans. A source value outside that set still parses, but it is held as an opaque marker with a label rather than a value. | Format | Source value | Label | |--------|-------------------------------------------------------------|---------------------| | TOML | a datetime | `datetime` | | TOML | a value with no neutral scalar | `value` | | HCL | `null` | `null` | | HCL | a string template or heredoc | `string template` | | HCL | a number with no `i64` or `f64` value | `number` | | HCL | any other expression, such as a function call or a variable | `expression` | | KDL | `#null` | `null` | | KDL | an integer beyond `i64` | `oversized integer` | | JSON | `null` | `null` | | JSON | an integer beyond `i64` | `oversized integer` | | JSON | a number whose `f64` value is not finite | `oversized number` | | YAML | `null`, `~`, or a key with no value | `null` | | YAML | an integer beyond `i64` | `oversized integer` | | YAML | a decimal float that overflows `f64` | `oversized number` | | YAML | an alias, `*name` | `alias` | | YAML | a tag the frontend refuses | `tagged value` | A marker is not an error by itself. It surfaces as an ordinary type mismatch when a spec field reads it, so `"when": null` under a string field reports `expected string, found null` at the value. It also refuses to emit to every format, because there is nothing faithful to write. ## Values a format cannot write The model can hold a value that a target format has no literal for. Emitting one returns an `EmitError` rather than inventing a syntax for it. | Target | Cannot write | Writes without trouble | |--------|------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| | TOML | nothing beyond the markers above | non-finite floats, `i64::MIN`, nested arrays, any key | | HCL | a non-finite float, `i64::MIN` | nested arrays, mixed arrays | | KDL | an array inside an array, an array mixing scalars and objects, an object inside a grouped repetition | non-finite floats, as `#inf`, `#-inf`, and `#nan` | | JSON | a non-finite float | everything else, `i64::MIN` included | | YAML | nothing beyond the markers above | non-finite floats, as `.inf`, `-.inf`, and `.nan`, nested sequences, any key | KDL's gaps all follow from one rule. A KDL argument must be a scalar, and the language has no inline array literal, so there is no way to write an inner array or an object inside a grouped repetition. YAML has no gap in this table, and it carries two markers no other format produces. An alias is not expanded, and a tag outside the core schema has no reading. A decimal that overflows `f64` refuses rather than becoming an infinity the operator never wrote. JSON refuses the same value for the same reason. HCL rejects `i64::MIN` because its parser reads the literal as a negation applied to a number that overflows on the way back in. For example, a KDL config with `rate #inf` converts to TOML, where it emits as `inf`. Converting the same config to JSON returns an error at `rate`, because JSON's grammar has no token for infinity. Block labels follow the same rule. HCL and KDL write a parsed block's native label back as label syntax, so `upstream "api" { ... }` round-trips. TOML, JSON, and YAML have no label syntax. A parsed tree does not name the schema field that holds the label. Emitting a natively labeled level into one of these formats returns an `EmitError` rather than dropping the label. To convert a labeled configuration into those formats, parse it through the spec and emit a spec walk such as `to_fields`, which writes the label as its designated child field. ## Names and repetition A name can also be one the target cannot write. HCL attribute and block names must be identifiers. TOML, KDL, JSON, and YAML quote any name, so a field named `not an ident` emits to all four and fails to HCL alone. This is the only source of an `UnrepresentableName` error. Repetition is format-specific, because each format refuses the shapes it would otherwise collapse silently. - TOML refuses a value beside a same-named block, two same-named values, and any repetition inside an inline table. - HCL repeats blocks freely and writes a value next to a same-named block, but it refuses a duplicate attribute name and any repetition inside an object. - JSON refuses a value beside a same-named block, because the only way to write it is a duplicate key, which most consumers collapse to one member. Repeated values and repeated blocks group into arrays instead. - YAML refuses a value beside a same-named block, for the same reason JSON does. Repeated values and repeated blocks group into sequences instead. - KDL writes every repetition but one. Repeated values group into one node's arguments, repeated blocks are the native list form, and a value beside a same-named block emits as two nodes. A grouped repetition holding an object cannot be written, because an argument must be a scalar. These shapes cannot come from a populated spec. They arise only when you emit a tree parsed from a format that permits them, or one you built by hand. ## What template generation can rely on A populated spec is the tree `to_fields` or `to_template` builds from your spec types and their defaults. It stays inside the vocabulary the formats share. Names are Rust identifiers, values are ordinary scalars, and nothing repeats. | Target | A populated spec fails when | |--------|-------------------------------------------------------------------------| | TOML | never | | KDL | never | | YAML | never | | JSON | a float default is infinity or NaN | | HCL | a float default is infinity or NaN, or an integer default is `i64::MIN` | If your defaults are ordinary numbers, template generation cannot fail, so you can `expect` on the emit call. ## What is dropped by design A few things are lost in conversion without an error, because they are presentation rather than configuration. - Operator layout and comments. Emit writes canonical text, and a parsed file's formatting is never held in the model. - Doc comments in JSON. The other formats render template annotations as comments, and JSON has no comment syntax, so a JSON template equals the populated output. - Which of two nesting syntaxes the source used. A TOML `[table]` and an inline table, an HCL block and an object attribute, or a YAML block mapping and a flow mapping all read as the same structure and emit in the target's canonical form. - Separate duplicate keys. JSON, YAML, and KDL group repeated names into one list on emit, so a list-shaped field reads the same list it would have. A single-value field trades its `duplicate field` report for a type mismatch on reparse, because the grouped member is an array where a scalar is expected. - The type of a layered override. Text from an environment variable or a command line flag reaches the model unparsed, and every format writes it as a string, so a typed reparse of the emitted file reads those leaves as strings. --- ## Language Server `confval-lsp` is a language server. It gives an editor the completion, hover, diagnostics, and navigation that [Editor Support](./editor-support.md) describes. The server works for any confval schema and any format confval parses, so you build one server for your own root spec. This page is for the developer who owns the spec and wants to run a server. ## Running a server for your spec Add the crate to the program that owns your spec: ``` cargo add confval-lsp ``` Bind the server to your `#[derive(Spec)]` root and a frontend, then run it over stdio. The `serve` function owns the connection, the initialize handshake, and the request loop. For example, serve an HCL document written against a `ServerSpec`: ```rust use confval_lsp::{serve, Hcl}; serve::(Hcl) ``` The derive supplies everything the server needs, so naming your root spec and its frontend is the whole binding. ## Trying it against an editor The crate ships a `serve` example bound to a demo spec, so you can point an editor at a running server before you write your own. Run it and choose a format: ``` cargo run -p confval-lsp --example serve hcl ``` The example serves over stdin and stdout, so an editor's LSP client launches the built binary at `target/debug/examples/serve` and speaks to it. The demo spec is there to show the feature set, not to deploy. Your real server names your own root spec. ## Choosing a format Every format is a cargo feature, and all of them are on by default. To ship a server for one format, turn the defaults off and enable that format. The build then carries one parser instead of all five. ```toml [dependencies] confval-lsp = { version = "0.8.0", default-features = false, features = ["toml"] } ``` ## Position encoding An editor addresses text by line and character, and the character count uses UTF-16 code units by default. The server negotiates the encoding at initialization and prefers UTF-8 when the client supports it, so a range over a value with non-ASCII characters stays aligned. This is automatic and needs no configuration. --- ## Layering When an application reads its configuration, the values may come from more than one place. A file holds the defaults that ship with the project, environment variables set the values a deployment needs, and command line flags override a value for a single run. Layering combines these sources into one configuration, applying them in order so that a value from a later source overrides the same value from an earlier one. The `layering` feature assembles the sources for you and produces the same spec type you would parse from a single file. Environment and command line values are coerced to the type each field declares. Every value keeps its source location, so a configuration error reports the exact file, variable, or flag responsible. ## Enabling layering Layering is an opt-in feature. Add it alongside a format frontend: ```shell cargo add confval --features "toml,derive,layering" ``` The feature brings in no external crate. ## A first assembly Each configuration source becomes a layer through a provider function. A file uses `parse_hcl_fields`, `parse_toml_fields`, `parse_kdl_fields`, `parse_json_fields`, or `parse_yaml_fields`, the environment uses `env_fields`, and the command line uses `cli_fields`. You pass the layers to `Assembly` in precedence order and call `assemble` with the spec type you want: ```rust use confval::layering::{Assembly, cli_fields, env_fields}; use confval::format::toml::parse_toml_fields; use confval::prelude::*; let mut sources = SourceMap::new(); let mut report = Report::new(); let base = sources.add("server.toml", file_text); let spec: Option = Assembly::new() .merge(parse_toml_fields(&sources, base, &mut report)) .merge(env_fields(&mut sources, "APP_", &mut report)) .merge(cli_fields(&mut sources, std::env::args(), &mut report)) .assemble(&mut report); ``` `assemble` merges the layers and runs the spec's parser once on the result. The value it returns is the same `ServerSpec` you would get from a single file, so you validate, gate, and lower it exactly as the [pipeline](../pipeline.md) describes. ## Building layers Each provider function reads one source and returns a layer. The file providers read a source you have already registered: ```rust let file_layer = parse_toml_fields(&sources, base, &mut report); ``` The environment and command line providers register their own sources as they read, so they take the source map by mutable reference: ```rust let env_layer = env_fields(&mut sources, "APP_", &mut report); let cli_layer = cli_fields(&mut sources, std::env::args(), &mut report); ``` A provider returns `None` when its source fails to parse. The error is recorded in the report. When any layer is `None`, `assemble` returns `None` before parsing the spec, so check the report for errors after `assemble` as you would after parsing one file. `assemble` never returns `None` with an empty report. ## Precedence The call order sets precedence. `merge` lets a later layer override a value an earlier layer set: ```rust let spec: Option = Assembly::new() .merge(file_layer) // base .merge(env_layer) // overrides the file .merge(cli_layer) // overrides the environment .assemble(&mut report); ``` `join` lets an earlier layer keep its value and fills only what it did not set. Use it for a layer of fallback defaults that should not override anything already present: ```rust let spec: Option = Assembly::new() .merge(file_layer) .join(defaults_layer) // fills gaps only .assemble(&mut report); ``` When two layers set the same nested block, the blocks combine field by field. When two layers set the same array, the higher-precedence array replaces the lower one. A repeated block follows the array rule, with one boundary case. The merge reads the parsed tree without the schema. A repeated block holding one instance in both layers looks the same as a singleton block. The two instances therefore combine field by field. With more than one instance on either side, the higher-precedence group replaces the lower one whole. An overlay that should replace a repeated block deterministically therefore lists every instance it wants, rather than a partial instance to merge. ## Environment variables `env_fields` reads process environment variables that begin with a prefix. The prefix is stripped, a double underscore separates nesting levels, a single underscore stays part of a name, and each segment is lowercased: ```rust let env_layer = env_fields(&mut sources, "APP_", &mut report); ``` With the prefix `APP_`, variables map to fields like this: - `APP_PORT=8080` sets `port`. - `APP_SERVER__MAX_BODY_MB=16` sets `server.max_body_mb`. Prefix matching is case-sensitive and byte-exact, so `app_port` does not match the prefix `APP_`. Write the prefix exactly as the variables begin, including its trailing underscore. An empty prefix selects every variable in the process environment. ## Command line arguments `cli_fields` reads flags in the `--key=value` form. A dot separates nesting levels, and a segment keeps its underscores. Arguments that are not flags are ignored, so you may pass the whole argument list. A flag written without its value, such as `--port` for `--port=8080`, is reported as a warning, since the space-separated form sets nothing: ```rust let cli_layer = cli_fields(&mut sources, std::env::args(), &mut report); ``` Flags map to fields like this: - `--port=8080` sets `port`. - `--limits.mode=log` sets `limits.mode`. ## Value types A value from an environment variable or a command line flag is always text. Each value is coerced to the type its field declares, so you write it as a string and the field decides how to read it: - A field of type `i64` reads `"8080"` as the number `8080`. - A field of type `String` keeps `"123"` as the text `123`. A value that does not fit its field is reported as a type error that points at the variable or flag it came from. For example, `APP_PORT=high` for an integer field reports `expected integer, found string`. ## Unsupported values An environment variable or a command line flag sets one value at a path. Neither can set a list or a repeated block. A list keeps whatever the file layers provide, so change a list by editing a file. ## Running the example The crate ships a `layering` example that assembles one `ServerSpec` from a base file, a joined defaults file, the environment, and the command line: ```shell cargo run -q -p confval --example layering --features derive,color,toml,layering ``` The base file provides `hostname` and the `limits` block, the environment sets `port` and `limits.mode`, the command line sets `limits.max_body_mb` and `tls`, and the defaults file fills `workers` through `join`: ```shell listening on 127.0.0.1:9090 with 8 workers limits: max_body_mb=64 mode=log tls: true ``` --- ## Lowering Once a spec is validated, lowering converts it into a config type. A config type is the runtime form your program uses. Because lowering runs only after the [gate](../pipeline.md), the narrowing conversions inside it never see a bad value. ## Defining a config `#[derive(confval::Config)]` writes the `Lower` impl that converts a validated spec into a runtime config: ```rust #[derive(confval::Config)] #[confval(lower_from = ServerSpec)] pub struct ServerConfig { #[confval(lower(from = version, with = i64_to_u32))] pub version: u32, #[confval(nested)] pub limits: Option, pub ca_file: Option, } ``` The `Lower` trait is: ```rust pub trait Lower: Sized { fn lower(spec: &S, report: &mut Report) -> Option; } ``` Field rules: - **No attribute**: the field auto-maps via the `LowerAuto` trait, which strips `Located` wrappers without narrowing: `Located -> T`, `Option> -> Option`, `Vec> -> Vec`, `Located>> -> Vec`, and the optional variant of the last. - **`#[confval(nested)]`**: the field type implements `Lower` itself. Works for single, `Option`, and `Vec` shapes. - **`#[confval(nested, default)]`**: a non-optional config field lowered from an `Option>` spec field. When the source omits the block, `S::default()` is lowered in its place, so the runtime field is always populated while the spec stays source-faithful (an absent block stays `None`). This attribute also exists on the spec side, where it fills the omitted block during parsing instead of at lowering. See [Optional fields and defaults](./parsing.md#optional-fields-and-defaults) for the difference. - **`#[confval(lower(from = field, with = fn))]`**: explicit conversion through a function `fn(&SpecField, &mut Report) -> Option`. All narrowing (`i64` to `u16`, string to enum, string to `IpNet`) goes through these functions. `from` also accepts a tuple `(a, b)` when one config field derives from several spec fields. - **`#[confval(spec_only(field, ...))]`** at the struct level names spec fields that intentionally have no runtime counterpart. The generated impl destructures the spec exhaustively with no rest pattern. Adding a field to either struct without accounting for it on the other side is a compile error. The two structs therefore stay in agreement. ## Narrowing helpers `confval::pipeline::narrow` provides ready-made `with` functions. For integer width changes: `i64_to_u16`, `i64_to_u32`, `i64_to_u64`, `i64_to_usize`, and `opt_` variants for optional fields. They narrow with `try_from` rather than `as`. A value that does not fit is reported at its span and lowering fails, so a missing range rule is reported as a located error instead of silently truncating the value. `i64_secs_to_duration` (and `opt_i64_secs_to_duration`) route a seconds count through the same checked narrow into a `Duration`, rejecting a negative value rather than wrapping it. `i64_to_f64` widens to `f64` for the ratio and rate fields where an `as` cast cannot be named in a `with` attribute. `keyword::` lowers a validated keyword string into the enum that [`keyword_enum!`](./validation.md#keyword_enum) generates, reading that enum's `TryFrom<&str>`. Name it with a turbofish so the derive knows which enum to parse into. The field was validated against the same set the `TryFrom` accepts, so the conversion does not fail in a running pipeline. The helper reports at the value's span in two cases: a `keyword_set()` check left out of the `Validate` impl, and a hand-rolled keyword set that disagrees with its enum. `keyword_enum!` prevents the second case. `keyword_list::` does the same for a list field, lowering a `Vec>` into a `Vec`. Every element that fails is reported before the call returns, so an operator sees all of them in one run, and a single bad element leaves the whole field unlowered. `opt_keyword_list::` takes the wrapped optional list, `Option>>>`, and returns `Some(None)` for an absent field. It unwraps that wrapper as well as the `Option`, which the other `opt_` helpers do not, because the wrapped shape adds a `Located` around the list. Validate a keyword list with [`check_each`](./validation.md#keywordset) so a bad element is reported at its own span during validation rather than through the lowering helper's defensive branch. ```rust use confval::pipeline::narrow; #[derive(confval::Config)] #[confval(lower_from = ServerSpec)] struct ServerConfig { #[confval(lower(from = port, with = narrow::i64_to_u16))] port: u16, #[confval(lower(from = mode, with = narrow::keyword::))] mode: LimitMode, } ``` --- ## 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](./validation.md). ## A first parse You define a spec as a struct, then parse a file into it with the frontend for the format you enabled. ```rust use confval::prelude::*; #[derive(confval::Spec)] struct ServerSpec { hostname: Located, port: Located, } let text = r#"hostname = "127.0.0.1" port = 8080 "#; let mut sources = SourceMap::new(); let mut report = Report::new(); let id = sources.add("server.hcl", text); let spec: Option = confval::format::hcl::parse_hcl(&sources, id, &mut report); ``` Every field is wrapped in a `Located`, which pairs the value with its source span, covered next. The parse checks structure only. It reports a missing field, a wrong type, or an unknown field, each at its span, and leaves what the values mean to [validation](./validation.md). ## Located values Every field on a spec is wrapped in a `Located`. ```rust pub struct Located { pub value: T, pub span: Span, } ``` A `Located` 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 line and column. `Span` and the `SourceMap` that resolves it are covered under [Diagnostics](./diagnostics.md#spans-and-source). 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` 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. ```rust #[derive(confval::Spec)] pub struct ServerSpec { pub version: Located, pub threads: Option>, #[confval(nested)] pub limits: Option>, #[confval(default = 30)] pub refresh_interval_seconds: Located, } ``` ### Field types A field's type tells the parser how to read it. These are the types you can use: - **Scalars**: `Located`, `Located`, `Located`, `Located`, and `Located`. - **Lists of strings**: `Vec>`, or `Option>>>` for an optional list. - **Nested structs**: another `Spec` type marked with `#[confval(nested)]`, described below. - **Maps**: an open-ended, string-keyed map, `BTreeMap>` marked with `#[confval(map)]`, 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::default()` | `expr` | | `Option>` | `Some(T::default())` | `Some(expr)` | | `Vec>` | empty list | compile error | | `BTreeMap<...>` with `#[confval(map)]` | empty map | compile error | | `Located` with `#[confval(nested)]` | `S::default()` | compile error | | `Option>` with `#[confval(nested)]` | compile error | compile error | | `Vec>` with `#[confval(nested)]` | compile error | compile error | | `Option>>>` | compile error | compile 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 attribute `#[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. One setting can use either, both, or neither. See [Lowering](./lowering.md#defining-a-config). ::: ### Deriving `Default` from the attribute defaults The attribute default fills a field the file omits. When the whole block is omitted, the config side supplies it through `#[confval(nested, default)]`, which lowers `S::default()`, so the spec type needs a `Default` impl. Writing that impl by hand repeats the attribute defaults. Nothing keeps the two in agreement. `#[confval(derive_default)]` on the struct generates the `Default` impl from the attribute defaults, so each default is declared once. ```rust #[derive(confval::Spec)] #[confval(derive_default)] struct LimitsSpec { #[confval(default = 16)] max_body_mb: Located, #[confval(default = "enforce".to_string())] mode: Located, } ``` This resembles `#[derive(Default)]`. The difference is where the values come from. The standard derive fills each field with `T::default()`, so a `Located` becomes empty and a `Located` becomes zero. `#[confval(derive_default)]` fills each field from its declared `#[confval(default)]` instead. It refuses a field that declares no default rather than inventing a value. Use `#[confval(derive_default)]` rather than `#[derive(Default)]` on a spec. The standard derive fills an undeclared field with `T::default()` without reporting it, so the value for an absent block and the value for a field the source omits can drift apart. `#[confval(derive_default)]` keeps those two values the same. The value it generates for a field is the value the parser fills when that field is absent. A field the parser would report as missing has no value to derive, so it is a compile error. A non-optional `Located` or `Located` with no default, and a `Vec>` with no default, each need a `#[confval(default)]` or a handwritten `impl Default`. An `Option` field and a nested list default on their own, because the parser already fills them when they are absent. The attribute is opt-in and additive, so a type that keeps its handwritten `impl Default` is unaffected. ### 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` - an optional struct, `Option>` - a list of structs, `Vec>`, which reads a block that may repeat ### Maps `#[confval(map)]` reads a field as an open-ended, string-keyed map, `BTreeMap>`. Use it for a setting whose keys are not known ahead of time, such as HTTP request headers or URL templates. The keys are open, so the parser reports no unknown field inside the map, and a duplicate key is an error. Each value keeps its span, so a `Validate` impl reports a bad entry at the entry. An operator writes the map as a block or as an inline map, and both read the same. A bare `#[confval(map, default)]` reads an absent map as empty. On the config side the map lowers to a plain `HashMap` or `BTreeMap` with no lowering function, because the two `LowerAuto` impls drop each value's span. Only a string-keyed map with string values is supported. A map of another value type, or a map of nested structs, needs a handwritten parser. ### Unknown fields The parser reports a setting that the spec struct does not declare as an unknown field error. There is no lenient mode that ignores extra settings. An agent editing a configuration file can add a setting the spec does not declare. The strict parse reports that setting instead of ignoring it. ### 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` in a spec by convention rather than an enum. [Writing parsers by hand](#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 point | Feature | Backed by | |-------------------------------------|---------|-----------------| | `confval::format::hcl::parse_hcl` | `hcl` | `hcl-edit` | | `confval::format::toml::parse_toml` | `toml` | `toml_edit` | | `confval::format::kdl::parse_kdl` | `kdl` | `kdl` | | `confval::format::json::parse_json` | `json` | `jsonc-parser` | | `confval::format::yaml::parse_yaml` | `yaml` | `saphyr-parser` | Each takes a `SourceMap`, a `SourceId`, and a `&mut Report`, and returns your spec as an `Option`. ```rust let spec: Option = 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. HCL has two ways to write a nested block, and both 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. For example, these two documents fill the same spec: ```hcl hostname = "127.0.0.1" port = 8080 allow = ["10.0.0.0/8", "192.168.0.0/16"] bind { port = 8080 } ``` ```toml hostname = "127.0.0.1" port = 8080 allow = ["10.0.0.0/8", "192.168.0.0/16"] [bind] port = 8080 ``` ```rust let spec: Option = confval::format::toml::parse_toml(&sources, id, &mut report); ``` Writing the model back out with `confval::format::hcl::emit_hcl` or `confval::format::toml::emit_toml` produces canonical text in the same shapes, values before blocks at each level: ```rust let text = confval::format::hcl::emit_hcl(&spec.to_fields())?; ``` KDL writes the same shapes with nodes. It parses with the KDL 2.0 grammar alone. A children block, `bind { port 8080 }`, and properties on one node, `bind port=8080`, are the same nested structure. A list is repeated arguments on one node, `allow "a" "b"`, or repeated same-named nodes, and a bare node is an empty list, the only form KDL has for one. For example, this document fills the same spec the HCL and TOML snippets above fill: ```kdl hostname "127.0.0.1" port 8080 allow "10.0.0.0/8" "192.168.0.0/16" bind { port 8080 } ``` ```rust let spec: Option = confval::format::kdl::parse_kdl(&sources, id, &mut report); ``` A repeated node is a list when the field is a list and a `duplicate field` error when it is not. A block node's first string argument is its native label, the `upstream "api" { ... }` idiom, and it fills the child field the spec marks with `#[confval(label)]`. A non-string label, an argument past the first, and a label on a block whose spec designates none are each reported. A bare node where a single value is expected reports `expected string, found array`, because a bare node means an empty list. Writing the model back out with `confval::format::kdl::emit_kdl` produces canonical KDL: ```rust let text = confval::format::kdl::emit_kdl(&spec.to_fields())?; ``` JSON has one way to nest, the object, which the model reads wherever it accepts a block. An array is a list. Its elements keep their own spans, so a bad entry is reported at that entry. The document root must be an object, because a configuration is a set of named fields. Any other root reports `expected an object at the document root` and yields no tree. An empty document reports the same thing. For example, this document fills the same spec the snippets above fill: ```json { "hostname": "127.0.0.1", "port": 8080, "allow": ["10.0.0.0/8", "192.168.0.0/16"], "bind": { "port": 8080 } } ``` ```rust let spec: Option = confval::format::json::parse_json(&sources, id, &mut report); ``` The frontend accepts strict JSON alone. A comment, a trailing comma, an unquoted property name, a missing comma between members, a single-quoted string, a hexadecimal number, and a number with a unary plus are each a syntax error. A file this frontend accepts carries nothing a strict JSON parser rejects, so a configuration written for confval also loads in tooling that does not use it. The frontend classifies a number by how it is written. `1` is an integer, and `1.0` and `1e3` are floats. An integer beyond the range of an `i64` becomes an oversized integer, so an `i64` field reports `expected integer, found oversized integer`. A number whose magnitude no `f64` holds, such as `1e999`, becomes an oversized number, so an `f64` field reports `expected number, found oversized number`. Each is reported at the value that used it. A `null` reports `expected string, found null` at the value that used it. The model has no null. Omit the member when you want an optional setting left unset. A duplicate key is a list when the field is a list and a `duplicate field` error when it is not. A scalar where a nested object is expected reports `expected block, found string`. The expected side of a mismatch is shared across formats, so the message names a block even though JSON has no blocks. Writing the model back out with `confval::format::json::emit_json` produces pretty-printed JSON with two-space indentation, values before nested objects, and a trailing newline: ```rust let text = confval::format::json::emit_json(&spec.to_fields())?; ``` JSON has no comment syntax, so emitted JSON carries no comments. [Templates](./templates.md#generating-a-template) covers what that costs a template. YAML nests two ways, the block mapping and the flow mapping, and the model reads both wherever it accepts a block. The document root must be a mapping, and any other root reports `expected a mapping at the document root`. An empty document, a whitespace-only file, and a file holding only comments each parse as a configuration that sets nothing, the way an empty TOML or HCL file does. A configuration file is one document, so a second one reports `expected a single document` rather than being discarded. For example, this document fills the same spec the snippets above fill: ```yaml hostname: "127.0.0.1" port: 8080 allow: ["10.0.0.0/8", "192.168.0.0/16"] bind: port: 8080 ``` ```rust let spec: Option = confval::format::yaml::parse_yaml(&sources, id, &mut report); ``` A plain scalar resolves through the YAML 1.2 core schema, and a quoted, literal, or folded scalar is a string whatever its text. `port: 8080` is an integer and `port: "8080"` is a string. The 1.1 literals `yes`, `no`, `on`, and `off` are not in the 1.2 schema, so `country: no` is the string `no` rather than a boolean. The same exclusion makes `-.nan`, an uppercase or signed base prefix such as `0X1F` or `-0x10`, and an underscored number such as `1_000` strings as well. A `null`, written `null`, `~`, or as a key with no value, reports `expected string, found null` at the value that used it. An integer beyond the range of an `i64` reports `expected integer, found oversized integer`. A number whose magnitude no `f64` holds reports `expected number, found oversized number`, so `.inf` written by an operator is the only infinity the model holds from YAML. A duplicate key is a list when the field is a list and a `duplicate field` error when it is not. An alias is not expanded. It reports `expected string, found alias` at the alias, so the field that used it says what is wrong rather than reporting itself absent. An anchor is read through, because the anchored node is ordinary data wherever it stands. A merge key, `<<`, is an ordinary key, so a spec that does not declare it reports `unknown field: <<`. The core schema tags resolve their text, so `!!str 8080` is the string `8080` and the non-specific `!` resolves the same way on a scalar. Three things report `expected string, found tagged value`: a core scalar tag whose text it cannot read such as `!!int foo`, a core tag on the wrong node kind such as `!!int {a: 1}`, and any tag outside the core schema. A key that is a mapping, a sequence, or an explicit `? *alias` has no field name the model can hold. It reports `expected a scalar key` and the entry is skipped, so one exotic key does not hide the errors after it. An alias written as a plain key, `*a: 1`, is a syntax error instead, because the parser rejects it before the frontend sees it. A scalar key reads as its text whatever the schema would resolve it to, so `8080:` names the field `8080`. A scalar where a nested mapping is expected reports `expected block, found string`, because the expected side of a mismatch is shared across formats. Writing the model back out with `confval::format::yaml::emit_yaml` produces block-style YAML with two-space indentation, values before nested mappings, and a trailing newline: ```rust let text = confval::format::yaml::emit_yaml(&spec.to_fields())?; ``` Every string emits double-quoted, so a value the schema would otherwise resolve, `no` or `123` or `null`, reads back as the string it was. A list field also accepts a single string as a one-element list, in every format. KDL has no array literal, so it writes a one-element list as a single value. Every frontend applies the same rule, so one configuration reads the same way whichever frontend parsed it. :::note `hcl-edit` rejects duplicate attribute keys while parsing, so a repeated attribute is a syntax error, and TOML rejects a duplicate key the same way. A repeated block parses, and confval reports it with a related span pointing at the first occurrence. A repeated KDL value node follows the same rule. A list field accumulates the occurrences, and a single-value field reports the repeat with the related span. JSON and YAML both permit the same key twice, so a duplicate key parses and the spec's declared shape decides what it means. ::: ## Writing parsers by hand Sometimes a block's remaining fields depend on the value of a discriminator field. The `Spec` derive cannot express that shape, so you write the parser yourself. The `Spec` derive covers plain structs, which is nearly everything given the confval [pipeline contract](../pipeline.md). 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`. Validation checks it against a [KeywordSet](./validation.md#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. ```rust pub trait FromFields: Sized { fn from_fields(fields: &Fields, report: &mut Report) -> Option; } ``` A `Fields` built by `to_template` can also hold commented-out entries, fields whose `commented` flag is set. Such a field reads as absent. `Fields::get` and `Fields::has` skip one for you. If you iterate with `Fields::iter`, check the flag and skip the field the way the generated walk does. 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)`, `Bool`, or `Unparsed`. Integers and floats stay distinct so a format that separates them, like TOML's `1` and `1.0`, round-trips faithfully. - **`Unparsed(String)`** is the raw text of a value from a source that only carries strings, such as an environment variable or a command line flag. The leaf parsers coerce it to the type they expect, so the field's declared type decides what `"8080"` becomes. No file frontend produces it. A quoted string in a file stays a `String`. - **`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`. [Format Limitations](./format-limitations.md) lists every one of them, for every format. ### 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_path_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` Occurrence helpers decide what a repeated field means: - `first_occurrence`: records the first occurrence of a leaf field and reports a later one as a duplicate - `parse_single_struct`: the same guard around a nested block - `parse_string_list_occurrence`: accumulates a list field's occurrences into one list, in document order The derive wraps every leaf arm it generates in `first_occurrence`, so a derived spec reports a repeated field. A handwritten parser that assigns its slot directly takes the last value instead, with no diagnostic. KDL delivers a repeated scalar as separate fields, so the difference shows up in a real document. Reporting helpers keep messages uniform: `report_unknown_field`, `report_missing_field`, `report_duplicate_field`. Unknown fields are always errors. There is no lenient mode. The `handwritten` example calls these helpers against a tagged enum and a handwritten root. The test at `crates/confval/tests/handwritten_parity.rs` writes one spec both ways and asserts that the two write walks render the same text and agree on every span. ## Writing emitters by hand A type with a handwritten `FromFields` needs a handwritten `ToFields` too, because the derive generates one only for the types it parses. `ToFields` has two required walks. `to_fields` emits every field with its span detached, which is the populated view and the template. `to_source_fields` emits only the fields the source set, keeps their spans, and recurses into children with `to_source_fields` rather than `to_fields`. That output is the [source view](./representations.md). Writing both by hand means writing the field list twice and reproducing that difference on every line. `FieldsBuilder` takes the walk as a parameter instead, so you list the fields once: ```rust use confval::format::{Fields, FieldsBuilder, ToFields, Walk}; impl Server { fn build(&self, walk: Walk) -> Fields { FieldsBuilder::new(walk) .leaf("hostname", &self.hostname) .leaf_opt("port", self.port.as_ref()) .string_list("tags", &self.tags) .block("limits", &self.limits) .finish() } } impl ToFields for Server { fn to_fields(&self) -> Fields { self.build(Walk::Populated) } fn to_source_fields(&self) -> Fields { self.build(Walk::Source) } } ``` Each method takes the `Located` rather than the value inside it, so the builder has the span each walk needs. | Method | `Walk::Populated` | `Walk::Source` | |--------|-------------------|----------------| | `leaf`, `leaf_opt` | emits detached | emits with its span, or omits a detached one | | `string_list` | emits every element detached | emits the elements that carry a span, or omits the field | | `string_list_opt` | emits detached when present | emits when the wrapper carries a span, elements included | | `block`, `block_opt`, `block_list` | recurses with `to_fields` | recurses with `to_source_fields` when the block carries a span | | `block_opt_default` | fills an absent block from `S::default()` | omits an absent block | | `literal_string` | emits detached | emits detached | `block_opt_default` is the counterpart of `#[confval(nested, default)]`, whose populated walk shows the values the program will run with even for a block the operator never wrote. Use `block_opt` for an optional block with no default, which both walks omit when it is absent. `leaf` accepts the same types a derived spec field accepts, through the sealed `Leaf` trait: `String`, `i64`, `f64`, `bool`, and `PathBuf`. No crate outside confval implements it, so the list can grow in a minor release. A path emits as a string, the one lossy conversion, matching what the derive generates. `literal_string` is for a field your impl supplies rather than reads. That field is the discriminator on a tagged enum: ```rust match self { TlsSpec::Manual { cert, key } => builder .literal_string("mode", "manual") .leaf("cert", cert) .leaf("key", key), TlsSpec::Acme { domains } => builder .literal_string("mode", "acme") .string_list("domains", domains), }; ``` Both walks emit it, because a source view that dropped the tag would not reparse. The builder does not cover every shape a spec can hold. A string-keyed map has a derive form, `#[confval(map)]`, so it needs no handwritten walk, but the builder has no method for one. Build such a field directly with `Field::detached_value` or `Field::detached_block`. Locate it with `at` when it carries a span, then `push` it into the builder where it belongs: ```rust let field = Field::detached_value(name, value).at(span); builder.push(field); ``` The walk does not reach a pushed field. You decide what it carries. On a source walk that includes deciding whether the source set it. The builder still shapes every other field of the type. `at` sets the field's span and its source. An attribute's value takes the same span. A block's nested level keeps its own source and enclosing span. A sequence's elements keep the spans they were built with. A handwritten spec type also implements `Validate` and `ValidateNested`. `Validate` holds its rules. `ValidateNested` holds the descent into its children, the traversal the derive would have written from the struct definition. The `Self: ValidateNested` bound on `validate_all` makes omitting the traversal a compile error rather than a silently skipped subtree. A type in a required nested slot also needs `Default`, because the generated parser fills an absent block with it before reporting the block missing. `to_template` defaults to `to_fields` for a handwritten impl. That fallback recurses with `to_fields`, so doc comments stop at the first handwritten node and never reach anything below it. A derived block nested under a handwritten one renders without its comments. The `handwritten` example prints both sides of that boundary. --- ## Representations Sometimes you need to see what your service loaded, which may differ from the file on disk. This matters when you inspect a running service's configuration. Three value representations of one loaded spec are available: 1. The source view shows the configuration exactly as the operator wrote it, with no defaults applied. 2. The populated view shows the configuration the service resolved to, with every default filled. 3. The runtime view shows the typed values the program uses. A fourth view, the schema view, reads the type rather than a value. See [The schema view](#the-schema-view) below. The `representations` example prints all three value views from one spec. Run it with: ```shell cargo run -q -p confval --example representations --features derive,serde,toml ``` The source sets only `mode`, leaving `max_body_mb` to its default, so the views differ exactly where a default fills a gap: ```text + Source view (what was set): mode = "log" + Populated view (after defaults): max_body_mb = 16 mode = "log" + Runtime view (what runs): { "max_body_mb": 16, "mode": "log" } ``` ## The source view `to_source_fields` returns a `Fields` holding only the fields the source set. It is generated by `#[derive(Spec)]`. The prelude exports the `ToFields` trait that declares it, so `spec.to_source_fields()` works wherever the prelude is in scope. The result is the same format-neutral field model a frontend produces, so the ordinary emit functions render it in any format. ```rust use confval::format::toml::{emit_toml, parse_toml}; let spec: LimitsSpec = parse_toml(&sources, id, &mut report).unwrap(); let source = emit_toml(&spec.to_source_fields())?; ``` The view is decided one field at a time by whether the field's span is attached. Parsing gives every value it reads a real span into the source file, and every filled default carries a detached sentinel span instead. The source view keeps the attached values and drops the detached ones, so a default never appears as though the operator wrote it. Each value the view keeps carries its real source span, so a tool that wants to report where a value came from still has the location. A block the operator wrote with every inner field left to its default renders as an empty block, because the block itself was written but nothing inside it was. One shape cannot keep the distinction. A bare `Vec>` list holds no span of its own, so an empty list the operator wrote is indistinguishable from an absent one, and both are dropped. When the difference matters, use the wrapped form, `Option>>>`, which keeps the list's own span and so survives the source view even when empty. ## The populated view The populated view is the plain [populate](./templates.md) dump. `to_fields` fills every default the source omitted and the emitters render it. ```rust let populated = emit_toml(&spec.to_fields())?; ``` Where the source view answers what was set, the populated view answers what the service resolved to, so a field the operator left out appears here with its default value. ## The runtime view The runtime view is the lowered config serialized with serde. A lowered config holds plain runtime types, so deriving `serde::Serialize` on the config struct is the whole mechanism. ```rust #[derive(confval::Config, serde::Serialize)] #[confval(lower_from = LimitsSpec)] struct LimitsConfig { #[confval(lower(from = max_body_mb, with = narrow::i64_to_u16))] max_body_mb: u16, #[confval(lower(from = mode, with = narrow::keyword::))] mode: Mode, } ``` A `keyword_enum!` type serializes as its keyword string rather than its Rust variant name, so the runtime view shows a mode as `"log"`, exactly as the config file and the other two views do. This impl is behind confval's `serde` feature, so it appears only when you enable serde. ## The schema view The three views above read a value. The schema view reads the type, so `ServerSpec::schema()` is an associated function with no instance. It returns a `Schema` that names each field, whether it is required, and the kind it holds. For example, ask a spec type for its schema: ```rust use confval::schema::ToSchema; let schema = ServerSpec::schema(); ``` The [schema IR](./schema-ir.md) page covers what it carries, the attributes that declare a field's constraint and run it during validation, and why it needs no instance. ## Why a separate walk The populated view and the source view read the same spec, but neither can produce the other. The populate walk fills defaults and detaches every span, so it has no record of what the source set. The source walk reads the spec's spans directly, which is where the set-or-defaulted distinction lives. `to_source_fields` is therefore its own walk rather than a filter over the populated model. It is a required method on `ToFields`, because no default body could answer the question without reporting defaults as operator-written. A spec with a handwritten `ToFields` writes the source walk itself. Build it with `FieldsBuilder`, which takes the walk as a parameter and applies this rule per field, as [Writing emitters by hand](./parsing.md#writing-emitters-by-hand) describes. --- ## Schema IR Sometimes you need the type of a spec rather than a value of it. An editor writing completions is one example. Before an operator writes a value, the editor needs to know which fields are legal, which are required, what kind each one holds, and which values a closed-set field accepts. The value walks cannot answer that. `FromFields` reads a `Fields` and builds a spec, and `ToFields` walks a spec and builds a `Fields`. Both need an instance, and a populated `Fields` holds values, not declared types. The schema IR reads the type instead. `ToSchema::schema()` returns a `Schema` that describes the spec. It is an associated function with no `self`, so you call it without a value. For example, list the top-level fields and whether each is required: ```rust use confval::schema::ToSchema; let schema = ServerSpec::schema(); for field in &schema.fields { println!("{}: required={}", field.name, field.required); } ``` ## What a schema carries A `Schema` is one level of a spec: the type's doc comment and its fields in declaration order. Each `SchemaField` carries the field name as it appears in a config file, the field's doc comment, whether it is required, whether it declares a default, and its declared type. The declared type is a `SchemaType`. A scalar leaf carries its `ScalarType` and any constraint it declares. A string list is `StringList`, and a string-keyed map is `StringMap`. A nested block is `Block`, which holds the child level's own `Schema` and a `repeated` flag for a zero-or-more block list. A leaf reads its `ScalarType` from the Rust type, so `port: Located` is `Int` and `hostname: Located` is `String`. A `PathBuf` leaf reads as `Path`, the name for the path string an operator writes. A block recurses into the child's own `schema()`, so one call at the root builds the whole tree. The schema carries a scalar leaf's default rendered to text. The derive evaluates the default expression when `schema()` runs and stores the result on the field, so `#[confval(default = 4)]` reads back as `"4"`. A defaulted list, map, or block carries no text, because there is no single value to render. `has_default` still records that one applies. A handwritten spec carries a default the same way, through `with_default_text` beside the other builder calls. To render a whole document of defaults, use the [template](./templates.md) walk, `ServerSpec::default().to_template()`. ## When a field is required `required` answers whether an absent field is a parse error. A field is required when its shape needs a value and it declares no default. An `Option` field, a zero-or-more block list, and any field with a `#[confval(default)]` are not required. A defaulted field therefore reports `required` as false and `has_default` as true, whatever its shape. An editor reads `required` to report only the fields the parser would reject as missing. ## Recording constraints The derive cannot read a `Validate` body, so a closed-set field looks like a plain `Located` and a numeric range is invisible to the schema. Three attributes record a constraint on a scalar leaf so the schema can carry it. `#[confval(keywords = PATH)]` names a `keyword_enum!` type and requires a `String` leaf. The schema carries its allowed strings as `Constraint::Keywords`. `#[confval(range = PATH)]` names a `RangeConstraint` and requires an `Int` or `Float` leaf. The schema carries its bounds, units, and help line as `Constraint::Range`. `#[confval(references = )]` marks a `String` leaf whose value names another block by its label. The `` is the config field name of a labeled block, one that marks a child field with `#[confval(label)]`. The schema carries the target as `Constraint::References`. For example, attach a range to two integer fields: ```rust #[derive(confval::Spec)] struct ServerSpec { hostname: Located, #[confval(range = PORT)] port: Located, #[confval(default = 4, range = WORKERS)] workers: Located, } ``` An attribute on the wrong leaf, or on a list, a map, or a block, is a compile error. The attribute records the constraint for the schema. On a derived spec the derive also runs the check during validation. The `Validate` body therefore carries no line for that field. A handwritten spec still calls the check itself, because the derive generates nothing for it. ## How a reference resolves A reference names its target block by a bare name. The name resolves outward from the reference's enclosing block. The nearest enclosing scope whose schema declares a labeled block field of that name wins, and the root is searched last. Labels are collected within that one scope instance. So two sibling instances of the enclosing block may reuse a label, and a reference sees only the labels of its own scope. A field of the same name that is not a labeled block does not stop the search, so a reference field may carry its target's name. For example, a route names one of its own service's upstreams: ```rust #[derive(confval::Spec)] struct ServiceSpec { name: Located, #[confval(nested)] upstreams: Vec>, #[confval(nested)] routes: Vec>, } #[derive(confval::Spec)] struct UpstreamSpec { #[confval(label)] name: Located, port: Located, } #[derive(confval::Spec)] struct RouteSpec { #[confval(references = upstreams)] upstream: Located, } ``` Each route's `upstream` value resolves against the upstreams of its own service. A label defined in a sibling service is out of reach, and the same label in two services is not a conflict. ## Running the reference check `validate_all` does not run the reference check, because the check reads the whole document rather than one level's own fields. After you parse and validate, call `check_references` with the parsed `Fields`, the schema, and the report: ```rust use confval::pipeline::check_references; use confval::schema::ToSchema; if let Some(fields) = &fields { check_references(fields, &ServerSpec::schema(), &mut report); } ``` The pass reports an undefined reference, a duplicate label, and an empty label, each at its value's span. The language server runs the same pass in its diagnostics, so the editor and your pipeline report the same reference errors. ## Building and reading a schema The node types are `#[non_exhaustive]`. Build a `Schema` or a `SchemaField` through `Schema::new` and `SchemaField::new` rather than a struct literal. Read a node by its fields, and match a `SchemaType`, `ScalarType`, or `Constraint` with a wildcard arm. Your code then keeps compiling when a release adds a variant or a field. ## Handwritten specs `#[derive(Spec)]` writes `ToSchema` for you. A spec you write by hand implements it too, because a derived parent's `schema()` calls its child's. Build the tree through the same constructors. ```rust use confval::schema::{Constraint, Schema, SchemaField, SchemaType, ScalarType, ToSchema}; impl ToSchema for TlsSpec { fn schema() -> Schema { Schema::new( None, vec![SchemaField::new( "mode".to_string(), None, true, false, SchemaType::Scalar { leaf: ScalarType::String, constraint: Some(Constraint::Keywords(&["manual", "acme"])), }, )], ) } } ``` A reference field is declared the same way, with `Some(Constraint::References { block: "upstreams" })` as the constraint. The target block marks its label child by calling `as_label()` on that child's `SchemaField`. --- ## Templates A spec type already encodes the whole configuration surface. It names every field, holds every default, and carries the doc comment you wrote on each field. Once you have a spec, you can run [parsing](./parsing.md) backward and turn it into a configuration file. This backward direction is called populate. `to_fields` produces a plain configuration file with every default filled in. `to_template` produces the same file with each field's documentation rendered as a comment above it, so an operator who opens the file learns what every setting means. Either one is useful when you build a CLI command that writes a starter config, or when you want to show what the spec resolved to once its defaults were applied. The crate ships a `templates` example that parses a two-line config and emits it back as an annotated TOML template. Run it with: ```shell cargo run -q -p confval --example templates --features derive,color,toml ``` The source sets only `hostname` and `port`. Populate fills `workers` and `tls` from their defaults and fills the whole `limits` block, and emit renders each field's comment above it: ```toml # The address the server binds to. hostname = "127.0.0.1" # The port the server listens on. port = 8080 # The number of worker threads. workers = 4 # Whether TLS is enabled. tls = false # Request size and mode limits. [limits] # The maximum request body size, in megabytes. max_body_mb = 16 # How limit violations are handled. mode = "enforce" ``` ## Generating a template `to_template` is the method you call to produce an annotated template. It is generated by `#[derive(Spec)]`. The prelude exports the `ToFields` trait that declares it, so `spec.to_template()` works wherever the prelude is in scope. It returns a `Fields`, the same format-neutral field model a frontend produces when it parses a file, so the ordinary emit functions render it. ```rust use confval::format::toml::{emit_toml, parse_toml}; let spec: ServerSpec = parse_toml(&sources, id, &mut report).unwrap(); let template = emit_toml(&spec.to_template())?; ``` `emit_hcl` renders the same model as HCL. `emit_kdl` renders it as KDL, with each comment as a `//` line above its node: ```rust use confval::format::kdl::{emit_kdl, parse_kdl}; let spec: ServerSpec = parse_kdl(&sources, id, &mut report).unwrap(); let template = emit_kdl(&spec.to_template())?; ``` `emit_yaml` renders it as YAML, with each comment as a `#` line above its entry. JSON has no comment syntax, so `emit_json` renders no doc comments and skips commented entries. `emit_json(&spec.to_template())` therefore produces the same text as `emit_json(&spec.to_fields())`. A commented entry stands for a field the source does not set, so the emitted JSON still holds every value the spec carries, and it shows none of the settings the operator has not written. Use HCL, TOML, KDL, or YAML when you want an annotated template. A comment is indented to line up with the field it documents, so a comment inside a block is at the block's indentation: ```hcl # The address the server binds to. hostname = "127.0.0.1" # The port the server listens on. port = 8080 # The number of worker threads. workers = 4 # Whether TLS is enabled. tls = false # Request size and mode limits. limits { # The maximum request body size, in megabytes. max_body_mb = 16 # How limit violations are handled. mode = "enforce" } ``` TOML content is flat, so in a TOML template every comment is at column zero. Emit writes canonical text rather than rewriting a file a person authored. It drops the comments and layout the field model never held. A nested struct is written as a `[table]` in TOML, a block in HCL, or a children node in KDL. ## Writing the comments A field's comment comes from its Rust doc comment, so you write the documentation once and it serves both the code and the template. ```rust #[derive(confval::Spec)] struct ServerSpec { /// The port the server listens on. port: Located, /// Request size and mode limits. #[confval(nested, default)] limits: Option>, } ``` A multi-line doc comment renders as one `#` line per source line, and a blank line inside the comment renders as a bare `#`. When the template text should read differently from the rustdoc, set it on the field with `#[confval(doc = "...")]`. That text is used in place of the doc comment. ## The plain dump When you want the configuration file without any commentary, call `to_fields` instead. It returns the same populated `Fields` as `to_template` but with no comments attached, so the emitted file is a clean dump of values. ```rust let text = emit_toml(&spec.to_fields())?; ``` The two methods share one populated model and differ only in the comment lines, so the plain dump and the annotated template always describe the same configuration. A spec with a handwritten `ToFields` builds that model with `FieldsBuilder`, described in [Writing emitters by hand](./parsing.md#writing-emitters-by-hand). Because the model is the same `Fields` type parsing produces, anything that reads a parsed field model reads a populated one the same way. ```toml hostname = "127.0.0.1" port = 8080 workers = 4 tls = false [limits] max_body_mb = 16 mode = "enforce" ``` ## What gets filled Populate emits an active field only when there is a value to show. The rules follow from what the parser leaves in the spec: - A required field is always present, so it is always emitted. - A leaf with an attribute default is emitted with that default, because parsing already filled it when the source omitted the field. - A repeated block is emitted once per element. - An optional block is filled only when you mark it. See [Marking Optional Blocks](#marking-optional-blocks). A block that is present is populated in turn, so a block you wrote but left partial gains its own absent defaults. A block that is filled is populated to full depth, so one call at the root resolves a nested tree of defaults all the way down. ## Commented-out entries An absent optional field still exists in the spec. A template that hid it would leave you unaware the setting is available. `to_template` renders each one as a commented-out entry instead, with its doc comment above it, so the template documents every field the spec accepts while activating only the ones that carry a value. `to_fields`, the plain dump, emits no commented entries. Each shape renders a placeholder you overwrite when uncommenting: - An optional leaf with no default shows a zero value for its type, the empty string, `0`, `0.0`, or `false`. - An optional string list shows an empty list. - An unmarked optional block shows an empty block, because filling its contents needs an instance only the marker provides. - An empty repeated block shows one empty element. TOML's array-of-tables syntax keeps the repetition visible. HCL and KDL show a single block. The marker is each format's own. TOML and HCL prefix every line with a spaceless `#`, so an entry stays distinguishable from a `# ` doc comment. Uncommenting is deleting that one character: ```toml # The PID file path. #pid_file = "" #[[svc]] ``` ```hcl # The PID file path. #pid_file = "" #svc { #} ``` YAML uses the same spaceless `#`, with the marker after the indentation so deleting it leaves the entry at its own column: ```yaml # The PID file path. #pid_file: "" #svc: #- {} ``` An empty repeated block shows one `#- {}` element, because uncommenting must leave an empty instance of the right shape, and a bare `- ` would read as a null element. KDL uses its native slashdash, a disabled node the parser reads and discards. Uncommenting is deleting the `/-`: ```kdl // The PID file path. /-pid_file "" ``` A commented entry is invisible to every parser, so a template parses to the same configuration with or without its commented entries. ## Marking optional blocks An optional block is absent until you write it. Parsing keeps an absent `Option>` as `None`, so a spec read back from a file stays faithful to what the operator wrote. Populate has to know which absent blocks to fill and which to leave out, because a block the runtime never applies should not appear in a populated view. The `#[confval(nested, default)]` marker on an optional nested field is that signal. A marked block is filled from its type's `Default` when you populate, and an unmarked optional block is left absent. ```rust #[derive(confval::Spec)] struct ServerSpec { hostname: Located, port: Located, #[confval(nested, default)] limits: Option>, #[confval(nested)] telemetry: Option>, } ``` Here `limits` is filled from `LimitsSpec::default()` and `telemetry` is left absent. A marked block requires its inner type to implement `Default`. Deriving that with [`#[confval(derive_default)]`](./parsing.md#deriving-default-from-the-attribute-defaults) generates the impl from the same attribute defaults, so one declaration drives parsing, `Default`, and the populated output together. The marker changes populate only, so parsing still leaves an absent block `None` and the read path is unchanged. ## When emit fails Emit returns a `Result`, because not every field model can be written faithfully in every format. Emitting a populated spec to TOML always succeeds. TOML has a literal for every value populate produces and quotes any key, so `emit_toml(&spec.to_template())?` cannot fail on a populated model. Emitting a populated spec to HCL fails only for two numeric values HCL has no literal for. The first is `i64::MIN`, which HCL would write as a negation that overflows when it is read back. The second is a non-finite float, an infinity or a NaN, which HCL has no keyword for. A spec that holds neither emits to HCL without failing. Emitting a populated spec to JSON fails only for a non-finite float, an infinity or a NaN, which JSON has no literal for. `i64::MIN` emits, because JSON writes it as a plain integer. Emitting a populated spec to YAML never fails. YAML 1.2 writes an infinity and a NaN natively, and any key writes as a quoted string. [Format Limitations](./format-limitations.md) collects every format's gaps in one place. Emit can also fail on a `Fields` that a frontend parsed rather than populated, because a parsed model can carry a name or a value the target format cannot write. A value with no representation, such as a TOML datetime, fails in any format. A name that is not a valid identifier fails when you emit HCL, which has no way to quote it, while TOML and KDL quote it without trouble. HCL also writes a value and a block side by side under one name. A TOML key names one thing, so `emit_toml` refuses that pair rather than silently dropping one of the two. Neither format can write one name twice for plain values, so both emitters refuse that as well. `emit_json` and `emit_yaml` group a repeated name into one member holding a sequence, so a name used twice for plain values emits. Both refuse a value beside a same-named block. The only way either can write that pair is a duplicate key, which loses one of the two members. Each emit error names the dotted path of the field responsible, so a failure in a large tree points at its location. A tree assembled by layering can carry unparsed text from an environment variable or a command line flag. That text emits as a string literal, since its type was never decided. A typed reparse of the emitted file therefore reads those leaves as strings. ## Detached spans and the fixed point Every value populate produces carries a detached span, a span with no source location, because the value comes from the spec and not from a file. A parsed value points at the bytes it came from, while a populated value has no bytes to point at, so its span records only that the value was filled. Equality on a `Located` value ignores the span, so a populated spec compares equal by value to the same spec parsed back from its own emitted output. Populate only fills defaults, so running it on a spec that is already complete adds nothing. Populate is therefore a fixed point. Populate a spec, emit it, parse it back, and populate again. The result is the same configuration. --- ## Validation [Parsing](./parsing.md), which precedes validation, ensures the spec is structurally correct. Validation is where you exhaustively check what the values mean: ranges, allowed keywords, and rules that cross more than one field. confval provides a `Validate` trait, described under [Validate](#validate) below. Its main purpose is to be named in a bound on the [lower](./lowering.md) stage. Every spec lowered into a config must implement it, or the config does not compile. The bound guarantees a validator exists. It does not guarantee that every field is checked inside that validator. confval ships two domain-agnostic checks, `RangeConstraint` and `KeywordSet`. ## A first validator A spec type checks its own fields. The two mechanical checks, a numeric range and a closed keyword set, are recorded on the field, and the derive runs them. ```rust range_constraint!(PORT, i64, min: 1, max: 65535); keyword_enum!(pub LimitMode, { Enforce => "enforce", Log => "log", Off => "off", }); #[derive(confval::Spec)] struct ServerSpec { #[confval(range = PORT)] port: Located, } #[derive(confval::Spec)] struct LimitsSpec { #[confval(keywords = LimitMode)] mode: Located, } ``` A rule an attribute cannot express stays in a `Validate` impl. It reads the type's own fields and reports each problem at the field's span, so a rule that reads two fields lives here. You call `validate_all` once on the root spec. It runs each type's recorded checks and its `validate`, then descends into every nested block. ```rust spec.validate_all(&mut report); ``` [Recording a constraint on the field](#recording-a-constraint-on-the-field) covers the attributes, and the sections after it cover the handwritten rules. ## Where a rule lives Validation rules live in one of two places: a `Validate` impl and plain validator functions (when necessary). ### Validate trait implementations A `Validate` impl on a spec type holds rules that the type can check from its own fields. It receives `&self`, so it can read every field of that struct. A rule that spans several of the struct's fields can therefore live here. A nested child block is not this type's own field. A `Validate` impl therefore does not itself validate nested child specs. However, there is also no need to call `validate()` manually if you use `validate_all()` on the root spec. :::info You implement the `Validate` trait, but you call `validate_all` once on the root spec. More information on this can be found [in this section](#validate-impl-contains-the-rules-validate_all-runs-them). ::: ### Validator functions Validator functions are necessary primarily for cross-file and cross-block validation. Depending on the domain, there may be complex semantic rules between files or blocks. For example, imagine a server with a central configuration file that has global settings, like enabling TLS, and subconfiguration files that may or may not be correct if TLS is enabled. A validator function handles this case. A validator function takes whatever it needs to check and appends to the report: ```rust fn validate_tls_agreement(server: &ServerSpec, upstreams: &[UpstreamSpec], report: &mut Report) { /* ... */ } ``` Nothing generates these and nothing calls them for you. They run alongside `validate_all`, before the `has_errors` check that stops the run. ## RangeConstraint Numeric bounds are declared once and checked against located values: ```rust range_constraint!(PORT, i64, min: 1, max: 65535); range_constraint!(DRAIN, i64, min: 0, max: 300, units: "seconds"); range_constraint!(WORKERS, i64, min: 1, max: 512, help: "Match this to your CPU core count."); PORT.check_located(&spec.port, "port", report); ``` `check_located` emits an error at the value's span when out of range. When **help** is provided, it overrides the auto-generated suggestion. Otherwise, confval generates one like "Set port to at least 1". ## KeywordSet Closed sets of allowed keyword strings are checked against located values. This is the string counterpart of `RangeConstraint` for fields like strategies, log levels, and fail policies: ```rust const LOAD_BALANCING_STRATEGIES: [&str; 5] = ["failover", "round_robin", "request_pressure", "sticky_hash", "random"]; KeywordSet::new(&LOAD_BALANCING_STRATEGIES) .check_located(&spec.load_balancing_strategy, "load_balancing_strategy", report); ``` `check_located` reports `unknown {field}: {value}` at the value's span, with a help line of `expected one of: `. Every keyword field reports the same way, so a wrong value in any closed-set field produces the same message shape and lists the allowed set. A list of keywords is checked with `check_each`, which reports each bad element at its own span: ```rust LogEvent::keyword_set().check_each(&spec.events, "event", report); ``` Name the field in the singular, because the message describes one element. An operator who typos one entry reads `unknown event: reloded` under that entry rather than a message about the whole list. Both list shapes pass a slice, so a bare `Vec>` passes itself and a wrapped `Option>>>` passes `&list.value`. ## Recording a constraint on the field The two checks above are written once in a `Validate` body. A scalar field on a derived spec can instead record its constraint on the field, and the derive runs the check for you. `#[confval(range = PATH)]` on an `Int` or `Float` leaf, and `#[confval(keywords = PATH)]` on a `String` leaf, name the constraint the field must satisfy. `validate_all` runs the recorded check, so the field needs no line in `validate`. ```rust #[derive(confval::Spec)] struct LimitsSpec { #[confval(range = MAX_BODY_MB)] max_body_mb: Located, #[confval(keywords = LimitMode)] mode: Located, } impl Validate for LimitsSpec { fn validate(&self, _report: &mut Report) {} } ``` The attribute is then the single source for that field. It records the constraint for the [schema IR](./schema-ir.md) and runs the check, so the two cannot disagree. Only a scalar `range` or `keywords` field is recorded this way. A cross-field rule, an emptiness check, and a keyword list checked with `check_each` have no attribute, so they stay in the `Validate` body. Removing a `check_each` line because you recorded other fields drops that check with no compile error, so leave a keyword-list check in place. ## keyword_enum! A closed-set field is otherwise declared three times. A `const` slice of allowed strings feeds the `KeywordSet` check. A runtime enum holds the value the program runs on. A `TryFrom<&str>` impl bridges the two at lowering. Nothing keeps the three in agreement, so a variant added to one and not the others drifts. `keyword_enum!` declares all three from one table: ```rust keyword_enum!(pub LimitMode, { Enforce => "enforce", Log => "log", Off => "off", }); ``` The keyword on the right of each arrow is the single source of truth. For the visibility you give it, the macro generates the enum (deriving `Debug, Clone, Copy, PartialEq, Eq`), the allowed set as `LimitMode::KEYWORDS`, a `LimitMode::keyword_set()` accessor, `as_str`, a `TryFrom<&str>` that accepts exactly the keywords, and `Display`. With confval's `serde` feature enabled it also generates a `Serialize` impl that writes the keyword string, so a serialized config carries `"log"` rather than the Rust variant name `Log`. If you already wrote a `Serialize` for the enum yourself, remove it, because the two impls conflict. You check a keyword field in one of two ways. On a derived spec, record the set on the field and let the derive run the check: ```rust #[derive(confval::Spec)] struct LimitsSpec { #[confval(keywords = LimitMode)] mode: Located, } ``` On a handwritten spec, or from a validator function, call the accessor yourself: ```rust LimitMode::keyword_set().check_located(&self.mode, "mode", report); ``` Either way, a value that fails the check never reaches the `TryFrom`, so the enum and its allowed set cannot drift. To lower the validated string into the enum, name `narrow::keyword::` as the `with` function, which the [lowering](./lowering.md#narrowing-helpers) guide covers. ## Validate `Validate` holds the semantic checks a spec type can perform on itself: ```rust pub trait Validate { fn validate(&self, report: &mut Report); fn descend(&self) -> ControlFlow<()> { /* ... */ } fn validate_all(&self, report: &mut Report) where Self: ValidateNested { /* ... */ } } ``` `validate` is the only method with no default. It is the one to implement. The other two are covered [below](#validate-impl-contains-the-rules-validate_all-runs-them). A `Validate` impl checks what a spec value can prove from its own fields, reporting at the span each field already carries. Because it receives `&self`, it can read every field of that struct. A rule spanning two fields of the same spec type belongs here. It receives no span and no origin parameter, so two kinds of rule do not fit: - A rule that must report at the span of the block itself rather than at one of its fields, such as a required child that is absent. - A rule that needs something outside the struct, such as a sibling spec type or a value assembled from the whole configuration. Those belong in a validator function. Such a function holds the surrounding `Located` wrappers. It can therefore report at any span it needs. Beyond holding those checks, the trait gives the lowering bound something to name. The `Config` derive puts that bound on every generated `Lower` impl: ```rust #[derive(confval::Config)] #[confval(lower_from = ServerSpec)] struct ServerConfig { /* ... */ } // generates: impl Lower for ServerConfig // where ServerSpec: Validate + ValidateNested { ... } ``` A config whose spec has no `Validate` impl fails to compile. A spec that can be lowered into a runtime config but carries no validator is therefore unrepresentable. An empty impl satisfies the bound. A spec type with nothing worth checking writes one, which states that validation was considered rather than forgotten. Handwritten `Lower` impls add the same `where S: Validate + ValidateNested` clause directly. A flattening lowering, meaning one with no per-entity `Lower` impl, can put the bound on the function that performs it. The bound guarantees that the validator exists, but it does not make lowering call it, so validation stays an explicit step before the gate. The trait rules out a spec with no validator. ## `Validate` impl contains the rules, `validate_all` runs them A `Validate` impl covers one spec type's own fields. It does not reach the nested blocks underneath it, because those are separate types with rules of their own. The specs for a configuration surface form a tree. A traversal has to visit every node. That walk is generated rather than written by hand, though you can write it yourself. `validate_all` runs this type's `validate`, then descends into every `#[confval(nested)]` field, recursively. One call at the root therefore covers the whole spec tree: ```rust spec.validate_all(&mut report); ``` An absent `Option>` and an empty `Vec>` contribute nothing to the walk. Fields without `#[confval(nested)]` are skipped, because a scalar is checked by its own type's rules or not checked. The traversal itself is a generated `ValidateNested` impl, which is the second half of the lowering bound shown above. A spec type with a handwritten `FromFields` has no derive to generate it and writes the impl itself. :::warning Calling `spec.validate(&mut report)` at the top of a pipeline checks the root block and leaves every nested block unchecked. Nothing in the type system catches that, because both methods compile and both take the same arguments. Keep `validate` out of your call sites. The examples call `validate_all` inside the gate helper. `validate_all` then runs in the one place that decides whether a spec is safe to lower. ::: ### Pruning a subtree with `descend` Sometimes a block turns off the feature it configures while its child blocks remain in the file. If the traversal validates those children, the operator receives errors about settings that will not be used. Those errors then have to be separated from the ones that apply to the running configuration. The `descend` method lets a spec type skip its own children. It runs after `validate` and returns a `ControlFlow` value: - `ControlFlow::Continue(())`, the default, validates every nested child. - `ControlFlow::Break(())` stops, leaving the children unvisited. For example, an `UpstreamSpec` that has been disabled may skip the retry and timeout blocks beneath it: ```rust impl Validate for UpstreamSpec { fn validate(&self, report: &mut Report) { /* ... */ } fn descend(&self) -> ControlFlow<()> { if self.enable.value { ControlFlow::Continue(()) } else { ControlFlow::Break(()) } } } ``` You may also find `descend` useful when a spec has already reported that the file was written for a different schema version. The operator needs to correct the version before the individual field errors are worth reading. Because `descend` runs after `validate`, anything the type reported about itself stays in the report. Only the children are skipped. The `validate_traversal` example runs the same invalid configuration twice, changing only the `enable` field, and prints both reports. --- ## The Pipeline Contract confval sends one configuration file through a fixed sequence of stages: **parse**, **validate**, **gate**, and **lower**. This page is the contract for that sequence. It defines what each stage does, in what order, and what each stage may assume about the ones before it. The derives are designed around this ordering. The approach is inspired by ["Parse, don't validate"](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) by Alexis King, though it does not use newtypes to couple construction with validation. The pipeline, as a whole, is like a multi-pass parser acting on a set of in-memory intermediate representations of the configuration. ## The four stages ### 1. Parse (structural) A frontend (`parse_hcl`, `parse_toml`, `parse_kdl`, `parse_json`, or `parse_yaml`) builds the neutral `Fields`, runs `FromFields`, and reports shape problems. Unknown fields, wrong types, missing required fields, and duplicate blocks are reported with spans. Parsing continues across inputs. An input whose tree was built keeps flowing into validation even if some of its fields failed, so parse and validation problems appear together in one pass. Only an input that produced no tree (a syntax error) stops the load. ### 2. Validate (semantic) Validation checks ranges, closed sets, and cross-field rules against the spans stored in `Located` fields. Rules live in two places: a `Validate` impl on a spec type, which takes `&self` and `&mut Report`, and validator functions you write and call yourself. [Validation](./guide/validation.md#where-a-rule-lives) covers which rule goes where. One call runs the impls: ```rust spec.validate_all(&mut report); ``` `validate_all` runs a spec type's own rules and then descends into every `#[confval(nested)]` field, recursively. The descent comes from `#[derive(Spec)]`. A nested block added later is therefore validated without editing a validator. Calling `validate` instead checks the root and stops there. [Validation](./guide/validation.md#validate-impl-contains-the-rules-validate_all-runs-them) covers the distinction. Validation never panics. In a system with hot reload, a panic during a reload would crash a long-lived service on a simple misconfiguration. The issues are reported instead. Every validator appends issues to the report. An issue usually records a violated semantic rule rather than a handled Rust error. For example, a date might pass the parse stage, confirming it is a date, but violate a setting-specific rule requiring it to be at least 90 days in the future. Spans come from the `Located` fields, so validation works the same whether the spec was parsed from a file or constructed in code. A spec with `#[confval(references = ...)]` fields has one more semantic check, and it reads the parsed tree rather than `&self`. Run `check_references` beside `validate_all`, with the parsed `Fields`, the schema, and the report: ```rust use confval::pipeline::check_references; check_references(&fields, &ServerSpec::schema(), &mut report); ``` The pass resolves every reference against the labels its scope can see, and reports a duplicate label, an empty label, and a reference no label matches. [Running the reference check](./guide/schema-ir.md#running-the-reference-check) shows the wiring, and [How a reference resolves](./guide/schema-ir.md#how-a-reference-resolves) covers the scoping rule. :::info The `Validate` trait exists so the requirement can be written as a bound. Every generated `Lower` impl carries `where SpecType: Validate + ValidateNested`, so a config does not compile unless its spec has a validator and a traversal. That catches the forgotten validator and the unreachable child block. An empty `Validate` impl satisfies its half of the bound, so it does not prove any field is checked. Neither half makes lowering call the validator, which stays an explicit step before the gate. ::: ### 3. Gate Lowering must not run when the report contains errors. Nothing in confval enforces this. The caller performs the check. Call `report.has_errors()` after validation and return before lowering when it is true. [Getting Started](./getting-started.md#a-complete-example) shows the check in place. Report also has `has_warnings()` and `has_issues()` (i.e., has warnings or errors). You decide whether warnings also stop lowering. Exit the program when the report holds errors, or reject the hot reload request. Warnings can print without stopping either one. ### 4. Lower `Lower::lower` converts spec types to runtime config types. Because the gate ran, the narrowing conversions inside lowering (string to `IpNet`, `i64` to `u16`) are safe. A failure here indicates a missing validation rule rather than invalid input. Unlike parsing and validation, lowering does not accumulate errors. It reports the one error and short-circuits, since a lowering error is rare and means an earlier stage let something through. Say so in the message. For example, "this is likely a bug that should have been caught during validation". An operator reading that knows the problem is in the software rather than in their configuration file. Finally, the error still carries a span, so it renders with a source location like any other. ## Spec types vs. config types Each setting exists in two parallel structs. | Layer | Derives | Purpose | |------------|-----------------------------------|---------------------------------------------------------------| | **Spec** | `confval::Spec` (and `Serialize`) | Populated from the source file, with every field span-tracked | | **Config** | `confval::Config` (and serde) | Resolved, executable form used at runtime | Spec fields are wrapped in `Located`: ```rust #[derive(Debug, confval::Spec)] pub struct ServerSpec { pub version: Located, pub threads: Option>, #[confval(nested)] pub limits: Option>, #[confval(default = 30)] pub refresh_interval_seconds: Located, } ``` Config structs declare how each field lowers: ```rust #[derive(Debug, Clone, confval::Config)] #[confval(lower_from = ServerSpec)] pub struct ServerConfig { #[confval(lower(from = version, with = i64_to_u32))] pub version: u32, #[confval(nested)] pub limits: Option, pub ca_file: Option, // auto-mapped, Located stripped } ``` The generated lowering destructures the spec exhaustively, so adding a field to one side without accounting for it on the other is a compile error. ## Type selection principle **Spec types use the rawest type that parses infallibly**, meaning strings, `i64`, bools, and paths. The structural parsers never reject a value for semantic reasons, so a port of `99999` or a strategy of `"failovr"` parses fine and is caught by validation with a span, alongside every other problem. **Keyword fields are `Located` in specs.** Closed sets like strategies or log levels are validated against a constant slice with a help line listing the options. The runtime enum implements `TryFrom<&str>` and the conversion happens at lowering. A serde keyword enum in the spec layer would abort parsing with a single error instead of joining the report. **Config types use the fully parsed, typed form**, such as `IpNet`, `SocketAddr`, and runtime enums. Downstream code never re-parses a string it received from config. **Handwritten `FromFields` impls cover the shapes the derive does not.** Tagged unions parse their discriminator first and dispatch. A free-form block can be captured as an arbitrary value rather than a struct by reading the neutral field model directly. ## Both forms normalize Operators write nested structures either as blocks or as attribute-with-object, and real configs mix the two: ```hcl limits { enable = true } limits = { enable = true } ``` The `Fields` view normalizes both, so every nested spec accepts either form with identical spans and identical error messages. ## Runnable examples End-to-end examples ship in `crates/confval/examples/`. `hcl.rs`, `toml.rs`, `kdl.rs`, `json.rs`, and `yaml.rs` each hold a source document and the two format calls that parse and emit it. Everything after parsing lives in `common/mod.rs`: the spec types, the validators, the config types, and the lowering functions. All five share that file verbatim. Every stage after parsing is in one module that all five format examples share. The `common` module's comments explain the split. `issue_severity.rs` reuses the same types to show a warning passing the gate. `validate_traversal.rs` stands alone to show what `validate_all` reaches and what a `descend` override prunes. See [Getting Started](getting-started.md) to run them. --- ## confval v0.4.0 The `confval` and `confval-derive` crates now share a single version and are released together. From this release on, one version number and one set of release notes cover the whole workspace. {/* truncate */} ## Highlights ### Enforced `Validate` bound on specs A compiler error will occur if a spec does not implement the `Validate` trait. The trigger for this is lowering. The compilation error will be similar to this, where the trait is not implemented for the example `LimitsSpec`. It materializes as an `unsatisfied trait bound` on `#[derive(confval::Config)]`: ```shell error[E0277]: the trait bound `spec::LimitsSpec: confval::pipeline::Validate` is not satisfied --> crates/confval/examples/common/config.rs:21:10 | 21 | #[derive(confval::Config)] | ^^^^^^^^^^^^^^^ unsatisfied trait bound | help: the trait `confval::pipeline::Validate` is not implemented for `spec::LimitsSpec` --> crates/confval/examples/common/spec.rs:23:1 ``` To resolve such errors, implement the trait for the spec. ### Unified workspace versioning `confval` and `confval-derive` are now versioned in lockstep through `[workspace.package]`. `confval` pins its derive crate with `confval-derive = "=0.4.0"`, so a given `confval` always resolves the matching derive. ## Changed - `confval-derive` moves from `0.1.1` to `0.4.0` to align with `confval`. This is a version-number change only. There is no change to the derive macro output or its public API. ## Upgrading Depend on `confval` as before. The `derive` feature continues to pull in the matching `confval-derive` automatically. ```toml [dependencies] confval = { version = "0.4", features = ["derive", "hcl", "color"] } ``` --- ## confval v0.5.0 This release adds a way to automatically ensure all nested specs are validated. {/* truncate */} ## Highlights ### One call validates the whole spec tree `Validate` gains a `validate_all` method that runs a spec type's own rules and then descends into every `#[confval(nested)]` field, recursively. A `Validate` impl covers one spec type's own fields, so reaching a nested block used to mean calling into it manually from the parent spec's `Validate` impl. Forgetting that call left the nested block unvalidated with nothing to indicate it. ```rust spec.validate_all(&mut report); ``` The descent comes from `#[derive(Spec)]`, so a nested block added later is validated without editing a validator. See [Validation](/docs/guide/validation) for the full picture. ### Existing `Validate` impls do not change `Validate::validate` keeps its signature and stays the only method you have to write. `validate_all` and `descend` are provided methods, so an impl written against v0.4.0 compiles unchanged. ## Added - `Validate::validate_all`, the entry point that runs a spec's rules and then its nested children. - `Validate::descend`, which decides whether the children of a block are visited. - `ValidateNested`, the traversal trait that `#[derive(Spec)]` implements for you. - `ControlFlow` and `ValidateNested` in the prelude. - A `validate_traversal` example. ## Changed - `#[derive(Spec)]` now emits an `impl ValidateNested` alongside the `impl FromFields`. Nothing about the parser changed. - The bound on every generated `Lower` impl moves from `where S: Validate` to `where S: Validate + ValidateNested`. A spec that derives `Spec` satisfies the second half automatically. - A nested spec without a `Validate` impl is now a compile error at its parent rather than a block that is skipped. ## Upgrading Replace the top level `spec.validate(&mut report)` with `spec.validate_all(&mut report)`. Delete any calls into child specs from your `Validate` impls. A leftover call reports the child's issues twice, so it is worth a grep to find the calls and remove them: ```shell grep -rn ".validate(" crates/ ``` A spec with a handwritten `FromFields` has no derive to generate its traversal and needs its own `impl ValidateNested`. The lowering bound reports the missing impl. ```toml [dependencies] confval = { version = "0.5", features = ["derive", "hcl", "color"] } ``` --- ## confval v0.6.0 This release adds template generation, multi-source configuration layering, the `keyword_enum!` macro, and a way to derive a spec's `Default` from its attribute defaults. {/* truncate */} ## Highlights ### Render a spec back to a config file A spec already names every field, holds every default, and carries the doc comment you wrote on each field. Template generation runs that backward and turns a spec into configuration text. `to_fields` builds a populated file with every default filled in. `to_template` builds the same file with each field's documentation rendered as a comment above it. An emitter then serializes the model to a format, `emit_toml` for TOML and `emit_hcl` for HCL. ```rust use confval::format::toml::emit_toml; use confval::prelude::*; #[derive(confval::Spec)] struct ServerSpec { #[confval(doc = "The address the server binds to.")] hostname: Located, /// The port the server listens on. port: Located, /// The number of worker threads. #[confval(default = 4)] workers: Located, /// Request size and mode limits. #[confval(nested, default)] limits: Option>, } fn write_config(spec: &ServerSpec) -> Result<(), String> { // The plain dump fills every default and carries no comments. let plain = emit_toml(&spec.to_fields()).map_err(|error| error.to_string())?; print!("{plain}"); // The annotated template renders each field's doc comment above it. let annotated = emit_toml(&spec.to_template()).map_err(|error| error.to_string())?; print!("{annotated}"); Ok(()) } ``` A field's comment comes from its `///` doc comment. When the file text should read differently from the rustdoc, set it with the `#[confval(doc = "...")]` attribute. That text is used in place of the doc comment. ```rust #[derive(confval::Spec)] struct ServerSpec { #[confval(doc = "The address the server binds to.")] /// This rustdoc is ignored, because the attribute wins. hostname: Located, // ... } ``` See [Templates](/docs/guide/templates) for what populate fills, how a comment is rendered per format, and when emit can fail. ### Assemble configuration from many sources A single file used to be the whole configuration. Layering combines several sources into one spec, so a base file supplies the values, environment variables override them, and command line flags override those. Each source is a provider that yields the same neutral `Fields`. An `Assembly` folds the providers by precedence. `merge` lets a later source replace an earlier one, and `join` lets an earlier source stand while a later one fills only what is still missing. Precedence is the call order, so the assembly site defines the policy. Every value keeps its byte span, so a diagnostic points at the exact source it came from, whether that is a file, an environment variable, or a flag. ```rust use confval::format::toml::parse_toml_fields; use confval::layering::{Assembly, cli_fields, env_fields}; use confval::prelude::*; // `sources` holds the registered files, `base` and `defaults` are their ids, // and `report` accumulates diagnostics. let spec: Option = Assembly::new() .merge(parse_toml_fields(&sources, base, &mut report)) .merge(env_fields(&mut sources, "APP_", &mut report)) .merge(cli_fields(&mut sources, ["--limits.mode=log".to_string()], &mut report)) .join(parse_toml_fields(&sources, defaults, &mut report)) .assemble(&mut report); ``` An environment variable selects by a prefix, separates levels with a double underscore, and keeps a single underscore inside a key, so `APP_LIMITS__MAX_BODY_MB=64` sets `limits.max_body_mb`. A command line flag uses `--key=value` with a dot between levels, so `--limits.mode=log` sets the nested `mode`. A non-file value arrives as a string and is coerced to the field's declared type, so `--tls=true` sets a `bool` and `APP_PORT=9090` sets an `i64`. The feature is behind the `layering` flag. With the flag off, the crate behaves the same as before. ### Declare a keyword set and its enum in one place A closed-set field used to need three artifacts kept in sync by hand: 1. A const slice of the allowed strings, checked by `KeywordSet` during validation. 2. A runtime enum the config type holds. 3. A `TryFrom<&str>` impl that bridges the two at lowering. When they drifted, a value could pass validation and then fail to lower, or lower to a variant validation would have rejected. The `keyword_enum!` macro declares all three from one source. ```rust keyword_enum!(pub LimitMode, { Enforce => "enforce", Log => "log", Off => "off", }); ``` It generates the enum, a `KEYWORDS` slice, an `as_str`, a `keyword_set` constructor, a `TryFrom<&str>`, a `Display`, and a compile-time assertion that no keyword repeats. ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LimitMode { Enforce, Log, Off, } const _: () = ::core::assert!( ::confval::pipeline::keyword::__keyword_enum_keywords_unique(&["enforce", "log", "off"]), "keyword_enum!: two variants map to the same keyword" ); impl LimitMode { /// The allowed keyword strings, in declaration order. pub const KEYWORDS: [&'static str; 1usize + 1usize + 1usize + 0usize] = ["enforce", "log", "off"]; /// The keyword for this variant. pub const fn as_str(&self) -> &'static str { match self { Self::Enforce => "enforce", Self::Log => "log", Self::Off => "off", } } /// A `KeywordSet` over `KEYWORDS`, for a `Validate` impl to check a /// located value against. pub fn keyword_set() -> ::confval::KeywordSet<'static> { ::confval::KeywordSet::new(&Self::KEYWORDS) } } impl ::core::convert::TryFrom<&str> for LimitMode { type Error = (); fn try_from(value: &str) -> ::core::result::Result { match value { "enforce" => ::core::result::Result::Ok(Self::Enforce), "log" => ::core::result::Result::Ok(Self::Log), "off" => ::core::result::Result::Ok(Self::Off), _ => ::core::result::Result::Err(()), } } } impl ::core::fmt::Display for LimitMode { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { f.write_str(self.as_str()) } } ``` At lowering, the `narrow::keyword::` helper runs the generated `TryFrom` and reports at the value's span, so a keyword field needs no handwritten converter. See [Validation](/docs/guide/validation) for how a keyword set reports an unknown value. ### Derive Default from the attribute defaults A defaultable spec declared its defaults twice. The `#[confval(default = ...)]` attribute fills an absent field during parsing. A handwritten `impl Default` supplied the whole struct when an enclosing block was absent, which `#[confval(nested, default)]` reads on the config side. Nothing checked that the two agreed, so a block present with a field omitted and a block absent entirely could resolve to different values. `#[confval(derive_default)]` generates the `Default` impl from the same attribute defaults. One declaration then drives parsing, `Default`, and the populated template. ```rust #[derive(confval::Spec)] #[confval(derive_default)] struct LimitsSpec { #[confval(default = 16)] max_body_mb: Located, #[confval(default = "enforce".to_string())] mode: Located, } ``` See [Deriving Default from the attribute defaults](/docs/guide/parsing#deriving-default-from-the-attribute-defaults) for the rules. ## Upgrading Update the version and enable the features you use. - The write path needs `derive` and at least one frontend feature, `toml` or `hcl`, for the format you emit. - Configuration layering is behind the `layering` flag. ```toml [dependencies] confval = { version = "0.6.0", features = ["derive", "toml", "hcl", "color", "layering"] } ``` The release is additive, so existing code needs no change. Use the new features, especially `keyword_enum!` and `#[confval(derive_default)]`, to reduce boilerplate. --- ## confval v0.7.0 This release adds a KDL frontend, three ways to print what a service loaded, and commented-out entries so a generated template shows you every setting a spec accepts rather than only the ones that carry a value. {/* truncate */} ## Highlights ### KDL joins HCL and TOML If you write configuration in KDL, confval now parses and emits it, behind a `kdl` feature. ```kdl hostname "127.0.0.1" port 8443 allow "10.0.0.0/8" "192.168.0.0/16" limits { mode "log" } ``` A node with only arguments is a value. A node with properties or children is a block, so `tls cert="a.pem"` and `tls { cert "a.pem" }` describe the same block. Everything after the parse call is unchanged. The `hcl`, `toml`, and `kdl` examples run the same steps in the same order, and only the source text and the two format calls that parse and emit it differ. See [Parsing a file](/docs/guide/parsing#parsing-a-file) for the entry points, and [Examples](/docs/examples) for the three side by side. ### Three representations from one loaded spec When someone asks what a service is running, there are three useful answers. confval now produces all three. The source view, `to_source_fields`, is the configuration as it was written, with defaults left out. It answers what was set in the file. Each value it keeps carries the span it came from, so a tool can still report where a value was written. The populated view, `to_fields`, fills every default and answers what the service resolved to. The runtime view is ordinary serde on the lowered config and answers what is running. All three render through the existing emitters, so you can print any of them in any format. See [Representations](/docs/guide/representations) for when to use each one and for the list shape that cannot keep the distinction. ### Templates show the settings you have not set An absent optional field used to vanish from a generated template, doc comment included, so the template never told you the setting existed. `to_template` now renders each one as a commented-out entry with its doc comment above it. TOML and HCL prefix every line of the entry with a `#` and no space, so enabling a setting means removing that character from each of the entry's lines. ```toml # The PID file path. #pid_file = "" ``` KDL prefixes the node with `/-`, its own marker for a node the parser skips, so one deletion enables a whole block. Every parser ignores a commented entry, so a template describes the same configuration whether you enable a setting or not. The plain dump, `to_fields`, emits none of them. See [Commented-Out Entries](/docs/guide/templates#commented-out-entries) for the placeholder each shape renders. ## Added - The `kdl` feature, with `parse_kdl`, `parse_kdl_fields`, and `emit_kdl`. - Commented-out entries in `to_template`, along with the `Entry` type, `Field::as_commented`, `Fields::from_entries`, `Fields::detached_entries`, and `Fields::entries` for a handwritten template walk. - `ToFields::to_source_fields`, the source view described above. - `ToFields::spec_doc` and `ToFields::type_doc`, and a struct-level `#[confval(doc = "...")]`. A template now takes a field's comment from the field's `doc` attribute, then the field's rustdoc, then the embedded type's own doc, so a spec documented once at its definition annotates every site that embeds it. - `FieldsBuilder`, `Walk`, and the sealed `Leaf` trait, for writing a `ToFields` by hand without listing the fields twice. See [Writing emitters by hand](/docs/guide/parsing#writing-emitters-by-hand). - `Field::parsed`, the constructor a format frontend builds its fields with, and `Field::at`, which locates a constructed field. - `Value::spanned`, the span-carrying value constructor. - `parse_path_field`, which reads a `Located` field. The derive now generates this call for that shape. - `first_occurrence` and `parse_string_list_occurrence`, the helpers behind the repeated-field handling described under Changed. - `KeywordSet::check_each`, and `narrow::keyword_list` and `narrow::opt_keyword_list`, for keyword list fields on the validation side and the lowering side. - `keyword_enum!` now generates a `serde::Serialize` impl behind the `serde` feature, writing the keyword string so a serialized config spells `"log"` rather than the Rust variant name. ## Changed - A repeated single-value field now reports a duplicate at the second occurrence and keeps the first value. The diagnostic's related span points at the first occurrence. It previously kept the last one silently. A list field accumulates its occurrences in document order. You reach this through KDL, where repeated nodes are the natural way to spell a list, or through a `Fields` value you build yourself. HCL and TOML reject a repeated key in their own parsers, so a file in either format is unaffected. - `parse_string_list_field` accepts a lone string as a one-element list, because a format with no array literal spells a one-element list as a single value. A lone `Scalar::Unparsed`, the kind an environment variable or a flag yields, stays a type mismatch. - `emit_hcl` writes values before blocks at each level, and puts a blank line above every block that follows another structure. This is the Terraform convention, and it matches the order TOML's syntax forces. It applies to every `emit_hcl` call, not to templates alone. - A level now holds `Entry` values rather than `Field` values, which is what carries the commented marker. `Fields::iter`, `get`, `has`, and the generated parse walk yield the fields a configuration sets, and never a commented entry. `Fields::entries` yields every entry and is what the emitters consume. - `Field` is now `#[non_exhaustive]`. ## Upgrading If you are upgrading from 0.6, four changes can break your build. Each one below names what to change. **`ToFields` gains a required method, `to_source_fields`.** If you derived your spec with `#[derive(Spec)]`, the derive generates the method and you have nothing to change. If you wrote a `ToFields` impl by hand, add the method and return the fields the source set. Build both walks with `FieldsBuilder`, which takes the walk as a parameter and omits a field with a detached span from the source walk, so you list your fields once rather than writing the span checks twice. See [Writing emitters by hand](/docs/guide/parsing#writing-emitters-by-hand) for the full impl. **`Field` is `#[non_exhaustive]`, so a struct literal no longer compiles.** You hit this if you maintain a format frontend outside confval that builds its own fields. Use `Field::parsed` on the read path, or `Field::detached_value` and `Field::detached_block` on the write path, then attach what the shape needs through `with_doc` and `at`. With the marker in place, the next field added to `Field` is a minor release rather than another break. **Enabling the `serde` feature adds a `Serialize` impl to every `keyword_enum!` type.** If you wrote your own `Serialize` for such an enum, remove it. Watch for this even if you did not enable `serde` yourself. Cargo unifies features across the dependency graph, so another crate turning on confval's `serde` turns this impl on for you. The conflict then appears as an error in your own code. **`Field` no longer carries a `commented` flag, and `Field::as_commented` returns an `Entry`.** You hit this if you wrote a `to_template` by hand that marks its own commented entries, or if you read `field.commented` anywhere. Build a level with `Fields::detached_entries` or `Fields::from_entries`, whose items are `Entry` values, and convert an active field with `.into()`. Read the marker through `Fields::entries` and `Entry::is_commented`. For example, a level with one active entry and one commented entry is built like this: ```rust Fields::detached_entries(vec![ Field::detached_value("port", port_value).into(), Field::detached_value("pid_file", placeholder).as_commented(), ]) ``` Update the dependency and enable the features you use. Add `kdl` only if you parse that format. ```toml [dependencies] confval = { version = "0.7.0", features = ["derive", "toml", "hcl", "kdl", "color", "layering"] } ``` --- ## confval v0.7.1 Add space between unit labels in range constraint for improved readability. {/* truncate */} ## Highlights ### Add space between unit labels in range constraint. This makes unit labels in help texts ready better. Before this change: ```console Set threads to at least 1thread(s) ``` After this change: ```console Set threads to at least 1 thread(s) ``` Originally this made sense in the case of a known abbreviated unit label, like "ms": ```console Set interval to at least 1ms ``` Now, the recommendation is the full human-readable unit label: ```console Set interval to at least 1 millisecond(s) ``` ## Upgrading Update the dependency and enable the features you use. ```toml [dependencies] confval = { version = "0.7.1", features = ["derive", "toml", "hcl", "kdl", "color", "layering"] } ``` --- ## confval v0.8.0 This release adds JSON and YAML frontends, a language server that works for any spec, block labels and cross-block references, a scaffolding command that installs agent skills, and a map field in the derive. {/* truncate */} ## Highlights ### JSON and YAML complete the five formats If you write configuration in JSON or YAML, confval now parses and emits it, behind a `json` and a `yaml` feature. ```json { "hostname": "127.0.0.1", "port": 8443, "limits": { "mode": "log" } } ``` Both frontends read into the same neutral field tree as HCL, TOML, and KDL, so everything after the parse call is unchanged. JSON is strict, so a comment or a trailing comma is a syntax error with a span rather than a silently accepted extension. YAML reads the one document a configuration file holds, with a nesting bound that reports a diagnostic rather than exhausting the stack on a hostile file. See [Parsing a file](/docs/guide/parsing#parsing-a-file) for the entry points, and [Format Limitations](/docs/guide/format-limitations) for what each syntax cannot express. ### A language server for any spec The new `confval-lsp` crate is a language server that works for any confval schema. You bind it to your `#[derive(Spec)]` root and a frontend, and one server serves an HCL, TOML, KDL, JSON, or YAML document written against that schema. It answers these requests: - diagnostics from the same checks your program runs - completion for attribute names, block types, and keyword values - hover with docs, defaults, and constraints - go to definition and references for labeled blocks - document symbols - a reset-to-default quick fix Completion and hover keep working while the buffer does not parse. ```rust use confval_lsp::{serve, Hcl}; serve::(Hcl) ``` See [Language Server](/docs/guide/language-server) to run one, and [Editor Support](/docs/guide/editor-support) for what it does in the editor. ### Blocks carry labels and reference each other A repeated block can now name its instances. `#[confval(label)]` marks the child field that holds the name, and the HCL and KDL frontends read and write the native label syntax, so `upstream "api" { ... }` fills the marked field. `#[confval(references = upstreams)]` marks a string field whose value must name a labeled block. The `check_references` pass resolves every reference against the labels in scope and reports the ones that do not match, with the declaration sites attached. The language server completes label values and navigates between a reference and its declaration through the same walk. See [How a reference resolves](/docs/guide/schema-ir#how-a-reference-resolves) for the scoping rule. ### The schema IR and recorded constraints `ToSchema`, generated by the derive, describes a spec as data: every field with its documentation, type, default, and constraints. The language server reads this IR for everything it serves, and a tool of your own can read it the same way. Two new attributes record a constraint on the field that carries it. `#[confval(range = PORT)]` and `#[confval(keywords = LimitMode)]` put the constraint into the schema, and the derive now runs the recorded check itself, so the line disappears from your `Validate` body. The attribute is the single source: hover shows the constraint, completion offers the keywords, and validation enforces it, all from one declaration. Cross-field and custom rules stay in `Validate`, so validation shrinks rather than disappears. See [Recording a constraint on the field](/docs/guide/validation#recording-a-constraint-on-the-field). ### confval init writes agent skills The crate now ships a `confval` binary. Run `cargo install confval`, then run `confval init` in a project. It writes two agent skills into `.claude/skills/`: `confval-init`, which scaffolds a spec from scratch, and `confval-add-block`, which extends an existing one. The skills pin the confval version they were written for, so a scaffolded project builds against the API the skill describes. See [Agent Skills](/docs/agent-skills) for what the skills contain and when to rerun `confval init`. ### A map field in the derive `#[confval(map)]` declares a string-keyed map field, for a setting whose keys are not known ahead of time, such as HTTP request headers or URL templates. Every frontend reads it from its natural syntax, a block or an inline object, and a repeated key reports a duplicate rather than silently overwriting. ## Added - The `json` feature, with `parse_json`, `parse_json_fields`, and `emit_json`. - The `yaml` feature, with `parse_yaml`, `parse_yaml_fields`, and `emit_yaml`. - `#[confval(map)]` and `parse_string_map_field`, for a setting whose keys are not known ahead of time. - The `confval-lsp` crate, with `serve`, the `Frontend` trait, and the `Hcl`, `Toml`, `Kdl`, `Json`, and `Yaml` frontends. - The `confval` binary, with `confval init` and the `confval-init` and `confval-add-block` agent skills. - The schema IR: the `ToSchema` trait, generated by the derive, and the `Schema`, `SchemaField`, `SchemaType`, `ScalarType`, and `Constraint` types in the `schema` module. - `#[confval(range = ...)]` and `#[confval(keywords = ...)]`, which record a constraint in the schema and run it during validation, replacing the equivalent `check_located` line in `Validate`. - `#[confval(label)]`, native block labels in the HCL and KDL frontends, and `Fields::with_label` and `Fields::label` in the neutral model. - `#[confval(references = ...)]` and the `check_references` pipeline pass. ## Changed - The `color` feature renders diagnostics through `annotate-snippets` rather than `owo-colors`. A rendered diagnostic now shows the source line with an underline and its related spans, in the rustc style. The rendered text differs from 0.7, so update anything that matches it, such as golden test output. - `parse_struct_list_field` accepts a lone map as a one-element list, because JSON and YAML write a single instance of a repeated block as a bare object rather than a one-element array. ## Upgrading No API from 0.7.3 was removed or changed in a way that breaks a build. The one behavioral change to watch is the diagnostic rendering under `color`, described above. Update the dependency and enable the features you use. Add `json` and `yaml` only if you parse those formats. ```toml [dependencies] confval = { version = "0.8.0", features = ["derive", "toml", "hcl", "kdl", "json", "yaml", "color", "layering"] } ``` To serve your spec to an editor, add `confval-lsp` beside it. ```toml [dependencies] confval-lsp = "0.8.0" ```