fig-schema 0.3.0

A generic, prov-agnostic schema layer over fig's value tree: field types, controlled vocabularies, and a reusable rule-matching engine.
Documentation

fig-schema

The schema layer over fig's value tree: what a field expects — its type, its allowed values, and how to present it.

fig parses bytes into a fig::Value and edits losslessly; it has no notion of "what is valid here". This crate adds that knowledge as a generic, embedder-agnostic engine. It never learns the word "prov" or "flower": a consumer defines its own constraint type and implements Validate on it, and FieldRule/Schema are generic over that type — so the path-matching and commit-time validation plumbing is written once, here, and reused everywhere.

What lives here

Type Role
PathPat / SegPat Match a fig path, including every item of a list (EachItem) and whole subtrees (AnyDepth)
FieldType The expected type: coercion of an edit buffer into it (FieldType::coerce), and whether a parsed value already has it (FieldType::admits)
Term / Cardinality / validate_enum A controlled vocabulary and the logic to check a value against one
Validation / Issue / IssueKind Why a value failed, as data rather than prose — or that it was not checked at all (IssueKind::Unchecked)
Schema::check / Verdict A whole document against the schema: every node's shape against its rule's type, and its value against the rule's constraint
Presentation / Icon / Tint Renderer-neutral display hints, carried but never interpreted
Consequence / Severity What changing a field costs, so a host can warn before an expensive or irreversible edit
lint_vocabulary / Finding Judge a vocabulary document rather than read it — what it declares that nothing acts on

Deliberately not here: a Constraint enum. Whether a field's constraint is a controlled vocabulary, a reference into a workspace, a range, or a pattern is the embedder's call.

Example

use fig::Value;
use fig_schema::{
    Consequence, FieldRule, FieldType, PathPat, Presentation, Schema, Seg, Severity,
    Term, Validate, Validation, validate_enum,
};

// The embedder's own constraint type — the seam this crate is built around.
struct Vocabulary { values: Vec<Term>, closed: bool }

impl Validate for Vocabulary {
    fn validate(&self, value: &Value) -> Validation {
        validate_enum(&self.values, self.closed, value)
    }
}

let schema = Schema::new(vec![
    FieldRule::new(PathPat::each_item_of("audience"))
        .ty(FieldType::Str)
        .constraint(Vocabulary {
            values: vec![Term::value("public"), Term::value("family")],
            closed: true,
        })
        .present(Presentation::default().title("Audience"))
        .on_change(
            Consequence::when("public", "Anyone with the link will be able to read this.")
                .severity(Severity::Confirm),
        ),
]);

let path = [Seg::Key("audience".into()), Seg::Index(0)];
let rule = schema.rule_for(&path).expect("a rule governs this path");

assert!(rule.validate(&Value::Str("public".into())).is_ok());

let rejected = rule.validate(&Value::Str("familly".into()));
assert!(rejected.is_reject());
assert_eq!(rejected.issue().unwrap().suggestion.as_deref(), Some("family"));

// Valid, but not free — ask before committing it.
assert_eq!(rule.severity_of(&Value::Str("public".into())), Some(Severity::Confirm));
assert_eq!(rule.severity_of(&Value::Str("family".into())), None);

The command line

cargo install fig-schema
fig-schema lint vocab/audience.figl

One command so far. lint reads a vocabulary document and reports what it declares that nothing acts on. Errors are findings that change what validation does; notes are findings that change only what a reader sees, and --strict fails on those too. It exits 0 when every file is clean, 1 when a document has an error, and 2 when the command line itself is wrong — a script sweeping a directory can tell "fix this document" from "fix this invocation" without reading the message.

The finding it exists for is values: cloesd. That parses, loads, and validates — as an open vocabulary, because parse_vocabulary asks only whether the spelling is exactly closed. Every value the author meant to forbid is then accepted, and nothing else in this crate can notice, because an open vocabulary that rejects nothing is indistinguishable from one that was meant to be open.

Installed on PATH it is also fig schema lint <file>: fig hands an action it has no verb for to a fig-<action> program, passing every argument through untouched, so the two compose with no registration step anywhere.

The commands with more reach — check, explain, complete — need a Schema, and a Schema is constructible only in Rust until the schema document format lands. The format is designed — docs/schema-format.md — and the loader is the open work; see docs/tasks/.

Two limits worth knowing, both inherited rather than chosen:

  • Findings carry a path, not a line and column. fig hands back a value tree with no per-node spans, so nothing downstream of a parse knows which line a key came from. vocabulary.values is the most any consumer of the parse can say.
  • It reads fewer formats than fig check does. json, jsonc, json5, yaml, toml and figl, plus a markdown file's frontmatter or endmatter block. fig's own check also takes xml, ini, dotenv, properties, nestedtext and the canonical form; the Rust binding has no variant for those, and the formats it does name beyond fig's default language set need the Zig core compiled from source, which cargo install must not require. fig convert moves a document into one of them.

Design notes

Rule precedence is declaration order. Schema::rule_for returns the first matching rule, so list a specific rule before a broader one that would also match. Schema::rules_for returns every match in that order — the winner first, then what it shadows — so a tool can show the precedence rather than leave it inferred.

A check never reports a missing field. A FieldRule says what a value must be if there is one, and nothing says there must be one, so an empty document is valid. Nor does it report a node no rule governs: a schema governs what it names, and a check that fires on correct documents is one people stop running.

Unchecked is not wrong. A constraint of a kind a validator does not know fails closed — Validation::Reject carrying IssueKind::Unchecked(kind) — so an editor that does not look further will not commit the value. A reader that does look can tell unchecked from invalid, and Verdict::is_unchecked is that look.

Retired terms warn, they don't reject. A Term marked retired is still a known value — it is merely no longer offered in a picker. A document that already holds one stays committable, even under a closed vocabulary.

Validation failures are structured. Issue carries the offending value, the kind of failure, and a near-miss suggestion when one exists. Display renders a reasonable English default; a frontend that wants to localize the text, or offer the suggestion as a one-tap correction, has the parts it needs.

A consequence names a destination, not a transition. Consequence::when matches the value being landed. This crate holds no current value, so it cannot see a change from anything — which also means asking about a value the field already holds answers the same as asking about a fresh one. Suppressing that no-op, and deciding what a deleted field resolves to, are the host's, and the host is the side that knows.

Costs and tints are different facts. A Tint says how loudly to draw a field; a Consequence says what happens if the user goes through with the change. A field can be drawn calmly and still be expensive, or drawn in red and cost nothing.

Coercion falls back to text. FieldType::coerce never destroys an edit: a value that doesn't fit the declared type becomes a Value::Str, leaving the caller's own reparse as the final backstop.

Licence

Licensed under either of Apache License, Version 2.0 or MIT license at your option.