Skip to main content
Version: 0.12.0

Schema IR

When you need the type of a spec rather than a value of it, the schema IR provides 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:

use confval::schema::ToSchema;

let schema = ServerSpec::schema();
for field in &schema.fields {
println!("{}: required={}", field.name, field.required);
}

What a schema holds

A Schema is one level of a spec: the type's doc comment and its fields in declaration order. Each SchemaField records:

  • The field name as it appears in a config file.
  • The field's doc comment.
  • Whether it is required.
  • Whether it declares a default.
  • Its declared type.

The declared type is a SchemaType:

  • A scalar leaf holds its ScalarType and any constraint it declares.
  • A string list is StringList, which holds any constraint its elements declare.
  • 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 list's constraint describes one element. An editor offering completion inside the list reads the same set a scalar field would give.

A leaf reads its ScalarType from the Rust type. port: Located<i64> is Int and hostname: Located<String> 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(). One call at the root builds the whole tree.

Default text

The schema records a scalar leaf's default rendered to text. The derive evaluates the default expression when schema() runs and stores the result on the field. #[confval(default = 4)] reads back as "4".

A defaulted list, map, or block has no text, because there is no single value to render. has_default still records that one applies. A handwritten spec records a default the same way, through with_default_text beside the other builder calls. To render a whole document of defaults, use the template 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 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. A closed-set field looks like a plain Located<String> and a numeric range is invisible to the schema. Five attributes record a value constraint so the schema can hold it. Two more attributes, #[confval(non_empty)] and #[confval(unique)], each record a precondition as its own flag.

#[confval(non_empty)]

The field is a String leaf or a string list. It rejects an empty or whitespace-only value. On a list it also rejects a list with zero elements. The schema records it as SchemaField::non_empty, a bool flag. The help line of non_empty(help = "...") is in SchemaField::non_empty_help. The Constraint slot stays free for a value constraint. A field can have both non_empty and a value constraint. It cannot have non_empty beside label, because check_references reports an empty label.

#[confval(unique)]

The field is a string list. It rejects an entry that repeats an earlier one. The schema records it as SchemaField::unique, a bool flag. The help line of unique(help = "...") is in SchemaField::unique_help. The Constraint slot stays free for a value constraint. unique combines with keywords, format, non_empty, and default, because the default list is empty and so unique.

#[confval(keywords = PATH)]

Names a keyword_enum! type. Takes a String leaf or a string list. The schema records its allowed strings as Constraint::Keywords. On a list the set describes one element, so an editor offers the same words inside the list that it offers on a scalar.

#[confval(range = PATH)]

Names a RangeConstraint. Requires an Int or Float leaf. The schema records its bounds, units, and help line as Constraint::Range. A list of numbers is not a field shape confval parses, so a range has nothing to bound on a list.

#[confval(length = PATH)]

Names a LengthConstraint. Requires a String leaf. The schema records its bounds and help line as Constraint::Length. The bounds are character counts.

#[confval(format = PATH)]

Names a type that implements Format. Takes a String leaf, a Path leaf, or a string list. The schema records the format's name and its check as Constraint::Format. An editor reads the name for the hover. It calls the check before it offers a default as a fix.

#[confval(references = <block>)]

Marks a String leaf whose value names another block by its label. Takes a leaf alone, because the reference pass resolves one value against the labels in scope. The <block> is the config field name of a labeled block, one that marks a child field with #[confval(label)]. The schema records the target as Constraint::References.

For example, attach a range to two integer fields:

#[derive(confval::Spec)]
struct ServerSpec {
hostname: Located<String>,
#[confval(range = PORT)]
port: Located<i64>,
#[confval(default = 4, range = WORKERS)]
workers: Located<i64>,
}

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 has 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. 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 share its target's name.

For example, a route names one of its own service's upstreams:

#[derive(confval::Spec)]
struct ServiceSpec {
name: Located<String>,
#[confval(nested)]
upstreams: Vec<Located<UpstreamSpec>>,
#[confval(nested)]
routes: Vec<Located<RouteSpec>>,
}

#[derive(confval::Spec)]
struct UpstreamSpec {
#[confval(label)]
name: Located<String>,
port: Located<i64>,
}

#[derive(confval::Spec)]
struct RouteSpec {
#[confval(references = upstreams)]
upstream: Located<String>,
}

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:

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. A whitespace-only label is empty. The language server runs the same pass in its diagnostics. 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.

use confval::prelude::*;
use confval::schema::{Constraint, ScalarType, Schema, SchemaField, SchemaType};

length_constraint!(NAME_LEN, max: 63);

struct TlsSpec {
mode: Located<String>,
name: Option<Located<String>>,
}

impl ToSchema for TlsSpec {
fn schema() -> Schema {
Schema::new(
None,
vec![
SchemaField::new(
"mode".to_string(),
None,
SchemaType::scalar(
ScalarType::String,
Some(Constraint::keywords(&["manual", "acme"])),
),
)
.required(),
SchemaField::new(
"name".to_string(),
None,
SchemaType::scalar(ScalarType::String, Some(NAME_LEN.constraint())),
),
],
)
}
}

fn main() {
let spec = TlsSpec {
mode: Located::detached("acme".to_string()),
name: None,
};
assert_eq!(spec.mode.value, "acme");
assert_eq!(TlsSpec::schema().fields.len(), 2);
}

A reference field is declared the same way, with Some(Constraint::references("upstreams")) as the constraint. The target block marks its label child by calling as_label() on that child's SchemaField. A value constraint's record comes from the constant validate checks with. Recording a constraint on a handwritten spec shows the pattern.