Skip to content

Span-preserving parse & strictness

You write colmn: when you meant column:. What should happen? A lenient parser shrugs, ignores the key it doesn’t recognise, and runs your pipeline with a silently-missing setting, a bug you discover hours later in the output. Clinker takes the opposite stance: that typo is an error, raised at plan time, pointing at the exact line. Two design choices make that possible, and both are worth stealing.

  • Read the Spanned<T> wrapper and name what it carries beyond the value itself.
  • Explain what #[serde(deny_unknown_fields)] changes about parsing a config, and why a silent-ignore default is dangerous.
  • Write a strict, span-aware config reader that rejects an unknown key with a located error message.
  • Distinguish span-tracking (locating a value) from strictness (rejecting an unknown key), and name what the single from_str chokepoint buys on top of both.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • span: the recorded source location of a value.
  • Spanned<T>: a value paired with its span.
  • source position: a line/column coordinate into the source text.
  • diagnostic: an error rendered against its source span.

Choice one: every value remembers where it came from

Section titled “Choice one: every value remembers where it came from”

When serde turns YAML into a Rust struct, it normally throws away where each value sat in the file. That’s fine until you need to say “the problem is here.” So clinker parses into Spanned<T>, a wrapper that keeps the value and its source position:

clinker-plan ·yaml.rs ·Spanned type @19acdcb4
// re-exported by clinker from the serde-saphyr crate
pub struct Spanned<T> {
pub value: T,
pub referenced: Location, // where this value is used in the source
pub defined: Location, // where it was defined (e.g. a YAML anchor)
}

A Spanned<PipelineNode> is a pipeline node that still knows its line and column. The engine holds the whole pipeline as Vec<Spanned<PipelineNode>> precisely so that any later complaint (a bad reference, a security rejection, a type error) can be rendered as a diagnostic that points at the source, the way a good compiler underlines the offending token rather than saying “error somewhere in your file.”

This is the same philosophy as last lesson’s ValidatedPath: carry the extra fact in the type rather than recomputing or guessing it later. There the fact was “screened”; here it’s “came from line N.” It also rhymes with the RecordView<'a, S> you met back in Reading a field without copying: a Spanned<T> is a value bundled with a fact about where it lives: there a lifetime tying a borrow to its storage, here a span tying a value to its source line.

Choice two: unknown fields are rejected, not ignored

Section titled “Choice two: unknown fields are rejected, not ignored”

The strictness lives on the config structs themselves, via a serde attribute:

clinker-plan ·source.rs ·deny_unknown_fields doc @19acdcb4
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WatermarkConfig {
pub column: String,
// ... a stray `colmn:` key here is a PARSE ERROR, not a silent no-op
}

#[serde(deny_unknown_fields)] flips serde from “ignore keys I don’t recognise” to “refuse any key I don’t recognise.” That single attribute is what converts your colmn: typo from a silent misconfiguration into a loud, located failure before the pipeline runs. The attribute appears on config structs across the crate; it’s a house rule, not a one-off.

Notice these two choices are orthogonal: deny_unknown_fields decides whether the typo is an error; Spanned<T> decides whether the error can name the line. The next section builds both, in miniature, so you can see the seam between them.

Choice three: one chokepoint for all of it

Section titled “Choice three: one chokepoint for all of it”

Strictness and span-tracking only hold if nothing sneaks around them. So clinker funnels all YAML parsing through a single function. No other code is permitted to call the underlying parser directly:

clinker-plan ·yaml.rs ·from_str fn @19acdcb4
pub fn from_str<'de, T>(yaml: &'de str) -> Result<T, YamlError>
where
T: Deserialize<'de>,
{
if yaml.len() > MAX_INPUT_BYTES {
// pre-parse rejection — cheap, before the parser sees a huge input
return Err(YamlError(make_oversize_error(yaml.len())));
}
serde_saphyr::from_str_with_options(yaml, budget_options()).map_err(YamlError)
}

The module doc states the rule plainly: “This module is the single entry point for YAML parsing in clinker. No other code path is permitted to call serde_saphyr::from_str*.” Why insist on one door? Two reasons, both architectural:

  • Defence in depth. budget_options() caps input size (32 MB), nesting depth (256), and node count (100 000), disables !include, and enforces an alias/anchor ratio against “billion laughs” expansion attacks. A single chokepoint means those limits apply to every parse, with no forgotten code path that parses unbounded input.
  • Bus-factor containment. serde-saphyr is a pre-1.0, single-maintainer dependency. Routing every call through one wrapper means that if it ever needs replacing, there’s exactly one file to change, not a hundred call sites scattered across the crate.

A chokepoint is the parser-side cousin of the proof token: instead of trusting every caller to remember the limits, you make the one gate enforce them for everyone.

No serde needed to feel the two choices. You’ll build a tiny config reader that is both strict (rejects unknown keys) and span-aware (knows the line), using only std: first fully worked, then completing one gap, then writing the whole thing.

Here is the full reader. Run it: the good config prints each field with its remembered line, and the typo is rejected with a located message. Read the two marked branches; they are the entire difference between strictness and leniency.

rust // editable

The good config parses; the typo is rejected with line 2: unknown field \colmn`. That one if !allowed.contains(…)` branch is the whole difference between “fails loudly at plan time, here” and “fails mysteriously at run time, somewhere.”

Below, the parse loop already builds a Spanned and detects the unknown key, but the error string is left as a TODO. The reader has the line number (line) and the offending key (key) in scope. Fill in the format! so the message points at the source, the way a diagnostic should. Everything else runs as given.

rust // editable
💡 Hint 1
A diagnostic names two things: where (the line) and what (the unknown key). Both are already bound: line and key. Interpolate them into the message.
Show solution
return Err(format!("line {line}: unknown field `{key}`"));
rejected: line 2: unknown field `colmn`

The span (the line) is what turns a generic “unknown field” complaint into a located one. Drop it and the user is left grepping their file. That is exactly why clinker parses into Spanned<T> rather than plain values: so the line is already there when an error needs it.

Faded: write the strictness check yourself

Section titled “Faded: write the strictness check yourself”

Your turn with much less scaffolding. The Spanned struct, the loop, and the main are given, but the body of the loop that splits the line, enforces strictness, and records the span is yours to write. The rules: split on the first :; an unknown key is a located Err; a known key pushes a Spanned { value, line }.

rust // editable
Show solution
let (key, val) = raw
.split_once(':')
.ok_or_else(|| format!("line {line}: expected `key: value`"))?;
let key = key.trim();
if !allowed.contains(&key) {
return Err(format!("line {line}: unknown field `{key}`"));
}
out.push((key.to_string(), Spanned { value: val.trim().into(), line }));

Three moves, and they are the same three clinker makes through serde: a strict membership check that rejects unknown keys, a located error built from the line, and a span-preserving push that keeps the line attached to the value. The ? on split_once propagates the malformed-line error the same way every fallible step does. That’s the Result machinery from lesson 12 doing real work here.

The miniature mirrors clinker’s real shape. Strictness rides on the serde attribute you read above; the located part rides on parsing into Spanned<T> rather than plain values:

clinker-plan ·yaml.rs ·Spanned type @19acdcb4

What the engine enforces: because the whole pipeline is held as Vec<Spanned<PipelineNode>>, every node carries its line for free. A later pass that finds a bad reference doesn’t have to re-discover where the node came from: the span is already on it, the way RecordView already held its borrow rather than re-deriving it.

What a junior might misread: treating deny_unknown_fields and Spanned as one feature, or assuming the located error “just happens.” It doesn’t: strictness decides whether the typo fails, span-tracking decides whether the failure can name the line, and the single from_str door is what guarantees neither can be bypassed. Three separate decisions, one combined effect.

Two lessons, two flavours of “make the type carry the guarantee”: a proof token for security, spans-and-strictness for config. Both feed the same destination, the validated plan the executor runs.

You can now read the Spanned<T> wrapper, explain what deny_unknown_fields enforces, write a strict and located config reader, and separate span-tracking from strictness from the chokepoint that guarantees both. Next we meet the validated plan itself, and the typed handle that says “this has been fully compiled” the same unforgeable way ValidatedPath said “this was screened.”

Go deeper on the Rust (optional; the first goes to The Rust Book for first principles, the second to an in-track build):

Glossary terms used: span, Spanned<T>, source position, diagnostic.