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
ScalarTypeand 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 ownSchemaand arepeatedflag 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.