fig_schema/field.rs
1//! The generic rule-matching engine: [`FieldRule`] and [`Schema`], both
2//! parameterized over the embedder's own constraint type `C`. This crate
3//! supplies the matching and type-coercion machinery; `C` is where an
4//! embedder plugs in what a constraint actually *is* (a controlled vocabulary,
5//! a reference into a workspace, or a sum of both) by implementing
6//! [`crate::Validate`] on it.
7
8use fig::{ExtKind, Value};
9
10use crate::consequence::Consequence;
11use crate::path::{PathPat, Seg};
12use crate::present::Presentation;
13use crate::vocab::{Validate, Validation};
14
15/// The type a field expects. Drives type-directed parsing and widget choice.
16///
17/// `#[non_exhaustive]`: fig gains [`ExtKind`]s and a schema gains field shapes
18/// in ordinary releases, so a `match` needs a `_` arm. Constructing a variant
19/// is unaffected.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum FieldType {
23 Null,
24 Bool,
25 Int,
26 Float,
27 Str,
28 /// A link into the workspace (stored textually, like `Str`, but a reference).
29 Ref,
30 /// A format-specific scalar carried verbatim — a TOML datetime, a ZON enum
31 /// or char literal. Coercing to one keeps the value's native type instead
32 /// of quoting it into a string, so a TOML `date = 1979-05-27` survives an
33 /// edit as a date rather than becoming `date = "1979-05-27"`.
34 Extended(ExtKind),
35 Map,
36 Seq,
37}
38
39impl FieldType {
40 /// Coerce an edit-buffer string to this type — the schema-directed
41 /// counterpart of shape-guessing. A value that doesn't fit the type falls
42 /// back to a string (the caller's own reparse is the final backstop);
43 /// container types are not scalar-edited, so they also pass through as text.
44 ///
45 /// The numeric types go through [`Value::parse_number`], so the text fig
46 /// itself writes reads back unchanged — including the `.inf`/`.nan`
47 /// spellings `str::parse::<f64>` rejects. Those still have no
48 /// representation in JSON or TOML, so an embedder targeting those formats
49 /// should reject them before they reach here.
50 pub fn coerce(self, s: &str) -> Value {
51 let t = s.trim();
52 match self {
53 // Only the null spellings mean null; anything else is real text the
54 // user typed, and silently dropping it would lose their edit.
55 FieldType::Null => match t {
56 "" | "~" => Value::Null,
57 _ if t.eq_ignore_ascii_case("null") => Value::Null,
58 _ => Value::Str(s.to_string()),
59 },
60 // The YAML 1.1 spellings are all accepted: the field is *declared*
61 // a bool, so `yes`/`on` are unambiguous here — the "Norway problem"
62 // is a hazard of untyped inference, which is exactly what a schema
63 // replaces. The coerced value is canonical either way.
64 FieldType::Bool => match t.to_ascii_lowercase().as_str() {
65 "true" | "yes" | "on" => Value::Bool(true),
66 "false" | "no" | "off" => Value::Bool(false),
67 _ => Value::Str(s.to_string()),
68 },
69 // fig's own parser owns the widening rule (`i64`, then `u64`).
70 // It falls back to a float when the text is neither, which for a
71 // field declared `Int` is not a fit — so that lands in the string
72 // fallback like any other miss.
73 FieldType::Int => match Value::parse_number(t, false) {
74 Ok(v) if !v.is_f64() => v,
75 _ => Value::Str(s.to_string()),
76 },
77 // Via fig's parser so the `.inf`/`.nan` spellings fig *writes* read
78 // back as floats. `str::parse::<f64>` rejects them, so a no-op edit
79 // of a field holding `.inf` used to commit the string `".inf"` back
80 // over the float.
81 FieldType::Float => {
82 Value::parse_number(t, true).unwrap_or_else(|_| Value::Str(s.to_string()))
83 }
84 FieldType::Extended(kind) => {
85 if extended_text_fits(kind, t) {
86 Value::Extended {
87 kind,
88 text: t.to_string(),
89 }
90 } else {
91 Value::Str(s.to_string())
92 }
93 }
94 // A string/ref field keeps its literal text — the whole point of
95 // type-directed parsing: `"123"` in a `str` field stays a string.
96 FieldType::Str | FieldType::Ref | FieldType::Map | FieldType::Seq => {
97 Value::Str(s.to_string())
98 }
99 }
100 }
101}
102
103/// Whether `text` is shaped like a literal of `kind`.
104///
105/// A [`Value::Extended`] is printed verbatim and *unquoted*, so garbage here
106/// would emit a document the format can't reparse (`date = not a date`). This
107/// is a cheap shape guard, not a parser: it rejects what obviously can't be a
108/// literal and leaves the rest to the format's own reader.
109fn extended_text_fits(kind: ExtKind, text: &str) -> bool {
110 if text.is_empty() {
111 return false;
112 }
113 match kind {
114 // Digits and the punctuation that separates them.
115 ExtKind::OffsetDateTime
116 | ExtKind::LocalDateTime
117 | ExtKind::LocalDate
118 | ExtKind::LocalTime => text.chars().all(|c| {
119 c.is_ascii_digit() || matches!(c, '-' | ':' | '.' | '+' | 'T' | 't' | 'Z' | 'z' | ' ')
120 }),
121 // A bare identifier — the text excludes the leading dot.
122 ExtKind::EnumLiteral => {
123 let mut chars = text.chars();
124 chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
125 && chars.all(|c| c.is_alphanumeric() || c == '_')
126 }
127 // Stored as a decimal codepoint.
128 ExtKind::CharLiteral => text.chars().all(|c| c.is_ascii_digit()),
129 ExtKind::NumberSpecial => matches!(
130 text,
131 "Infinity" | "-Infinity" | "+Infinity" | "NaN" | "-NaN" | "+NaN"
132 ),
133 // `ExtKind` is `#[non_exhaustive]`: a fig version newer than this crate
134 // may add a kind we don't recognize yet. This is only a cheap shape
135 // guard (see the doc comment above), so defer to the format's own
136 // reader rather than reject a literal we simply don't have a rule for.
137 _ => true,
138 }
139}
140
141/// One field rule: which node(s) it governs, the type it expects, an optional
142/// constraint of the embedder's own type `C`, and how to present it.
143///
144/// `#[non_exhaustive]`: a rule gains ways to describe a field over time, so it
145/// is built from [`FieldRule::new`] and the chainable setters rather than a
146/// struct literal. Reading the fields is unchanged.
147///
148/// ```
149/// use fig_schema::{FieldRule, FieldType, PathPat, Presentation, Validate, Validation};
150/// # use fig::Value;
151/// # struct Vocab;
152/// # impl Validate for Vocab { fn validate(&self, _: &Value) -> Validation { Validation::Ok } }
153/// let rule = FieldRule::new(PathPat::each_item_of("audience"))
154/// .ty(FieldType::Str)
155/// .constraint(Vocab)
156/// .present(Presentation::default().title("Audience"));
157/// assert_eq!(rule.ty, Some(FieldType::Str));
158/// ```
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct FieldRule<C> {
162 /// Which node(s) this governs (reaches list *elements*, not only scalars).
163 pub at: PathPat,
164 /// The expected type — drives type-directed parsing and widget choice.
165 pub ty: Option<FieldType>,
166 /// A value constraint, in whatever shape the embedder defines.
167 pub constraint: Option<C>,
168 /// Renderer-neutral presentation hints.
169 pub present: Presentation,
170 /// What changing this field costs, if anything — see [`Consequence`].
171 ///
172 /// Beside the presentation hints rather than inside them: a cost is a fact
173 /// about the field, not a way of drawing it, and a host that ignores every
174 /// other hint must still honour these.
175 pub on_change: Vec<Consequence>,
176}
177
178impl<C> FieldRule<C> {
179 /// A rule governing `at`, with no type, no constraint and no presentation
180 /// hints — the parts a caller adds with the setters below.
181 pub fn new(at: PathPat) -> Self {
182 Self {
183 at,
184 ty: None,
185 constraint: None,
186 present: Presentation::default(),
187 on_change: Vec::new(),
188 }
189 }
190
191 /// Set the expected type. Takes a [`FieldType`] or an `Option<FieldType>`,
192 /// so a caller reading a config that may not declare one can pass it
193 /// straight through.
194 pub fn ty(mut self, ty: impl Into<Option<FieldType>>) -> Self {
195 self.ty = ty.into();
196 self
197 }
198
199 /// Set the value constraint.
200 ///
201 /// This one takes a `C` rather than an `impl Into<Option<C>>` the way
202 /// [`FieldRule::ty`] does: with `C` otherwise unconstrained, `Into` cannot
203 /// tell `C` from `Option<C>` and the call fails to infer. Use
204 /// [`FieldRule::constraint_opt`] for a constraint that may be absent.
205 pub fn constraint(mut self, constraint: C) -> Self {
206 self.constraint = Some(constraint);
207 self
208 }
209
210 /// Set the value constraint from an optional one. `None` leaves the rule
211 /// imposing nothing, which is what a type-only rule wants.
212 pub fn constraint_opt(mut self, constraint: Option<C>) -> Self {
213 self.constraint = constraint;
214 self
215 }
216
217 /// Set the presentation hints.
218 pub fn present(mut self, present: Presentation) -> Self {
219 self.present = present;
220 self
221 }
222
223 /// Declare one more consequence of changing this field. Appends, so a rule
224 /// can carry a blanket cost and a value-specific one by calling this twice
225 /// — see [`FieldRule::consequences_of`].
226 pub fn on_change(mut self, consequence: Consequence) -> Self {
227 self.on_change.push(consequence);
228 self
229 }
230
231 /// Set the whole consequence list at once, for a caller building it from a
232 /// config rather than declaring it inline. Replaces rather than appends.
233 pub fn on_change_all(mut self, consequences: Vec<Consequence>) -> Self {
234 self.on_change = consequences;
235 self
236 }
237}
238
239impl<C: Validate> FieldRule<C> {
240 /// Validate a candidate `value` against this rule's constraint. A rule with
241 /// no constraint (or a type-only rule) imposes nothing here.
242 pub fn validate(&self, value: &Value) -> Validation {
243 match &self.constraint {
244 Some(c) => c.validate(value),
245 None => Validation::Ok,
246 }
247 }
248}
249
250/// A set of field rules. Matched against a row's fig path to find what governs
251/// it.
252#[derive(Debug, Clone)]
253pub struct Schema<C> {
254 rules: Vec<FieldRule<C>>,
255}
256
257impl<C> Default for Schema<C> {
258 fn default() -> Self {
259 Self { rules: Vec::new() }
260 }
261}
262
263impl<C> Schema<C> {
264 /// Build a schema from its rules.
265 pub fn new(rules: Vec<FieldRule<C>>) -> Self {
266 Self { rules }
267 }
268
269 /// The rules, in declaration order.
270 pub fn rules(&self) -> &[FieldRule<C>] {
271 &self.rules
272 }
273
274 /// Whether the schema carries no rules (nothing to apply).
275 pub fn is_empty(&self) -> bool {
276 self.rules.is_empty()
277 }
278
279 /// The first rule whose pattern matches `path`, if any. Declaration order is
280 /// precedence, so a more specific rule should be listed before a broader one.
281 pub fn rule_for(&self, path: &[Seg]) -> Option<&FieldRule<C>> {
282 self.rules.iter().find(|r| r.at.matches(path))
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::vocab::Issue;
290
291 #[test]
292 fn type_directed_parse_keeps_a_string_field_a_string() {
293 assert_eq!(FieldType::Str.coerce("123"), Value::Str("123".into()));
294 assert_eq!(FieldType::Int.coerce("123"), Value::Int(123));
295 assert_eq!(FieldType::Bool.coerce("true"), Value::Bool(true));
296 // A non-fitting value falls back to a string (reparse is the backstop).
297 assert_eq!(FieldType::Int.coerce("abc"), Value::Str("abc".into()));
298 }
299
300 #[test]
301 fn a_null_field_keeps_text_it_cannot_read_as_null() {
302 assert_eq!(FieldType::Null.coerce(""), Value::Null);
303 assert_eq!(FieldType::Null.coerce("null"), Value::Null);
304 assert_eq!(FieldType::Null.coerce("NULL"), Value::Null);
305 assert_eq!(FieldType::Null.coerce("~"), Value::Null);
306 // Anything else is a real edit, and must not be silently dropped.
307 assert_eq!(
308 FieldType::Null.coerce("important data"),
309 Value::Str("important data".into())
310 );
311 }
312
313 #[test]
314 fn a_bool_field_accepts_the_yaml_spellings() {
315 for yes in ["true", "True", "TRUE", "yes", "Yes", "on"] {
316 assert_eq!(FieldType::Bool.coerce(yes), Value::Bool(true), "{yes}");
317 }
318 for no in ["false", "False", "FALSE", "no", "No", "off"] {
319 assert_eq!(FieldType::Bool.coerce(no), Value::Bool(false), "{no}");
320 }
321 assert_eq!(FieldType::Bool.coerce("maybe"), Value::Str("maybe".into()));
322 }
323
324 #[test]
325 fn a_float_field_reads_back_the_spellings_fig_writes() {
326 // fig serializes a non-finite float as YAML's `.inf`/`.nan`, so that is
327 // the text an edit buffer holds. `str::parse::<f64>` rejects it, which
328 // meant a no-op edit committed the *string* `".inf"` over the float.
329 let inf = FieldType::Float.coerce(".inf");
330 assert!(matches!(inf, Value::Float(f) if f.is_infinite() && f.is_sign_positive()));
331 let neg = FieldType::Float.coerce("-.inf");
332 assert!(matches!(neg, Value::Float(f) if f.is_infinite() && f.is_sign_negative()));
333 assert!(matches!(FieldType::Float.coerce(".nan"), Value::Float(f) if f.is_nan()));
334 // Rust's own spellings still work, and ordinary floats are unaffected.
335 assert!(matches!(FieldType::Float.coerce("inf"), Value::Float(f) if f.is_infinite()));
336 assert_eq!(FieldType::Float.coerce("1.5"), Value::Float(1.5));
337 assert_eq!(FieldType::Float.coerce("nope"), Value::Str("nope".into()));
338 }
339
340 #[test]
341 fn an_int_field_does_not_widen_to_a_float() {
342 // `Value::parse_number` widens to a float as a last resort; a field
343 // declared `Int` treats that as a miss, so the documented string
344 // fallback still applies rather than a silent change of type.
345 assert_eq!(FieldType::Int.coerce("3"), Value::Int(3));
346 assert_eq!(FieldType::Int.coerce("3.5"), Value::Str("3.5".into()));
347 // Past `i64::MAX` is the one place `Uint` is the canonical variant.
348 assert_eq!(
349 FieldType::Int.coerce("9223372036854775808"),
350 Value::Uint(9_223_372_036_854_775_808)
351 );
352 }
353
354 #[test]
355 fn an_extended_field_keeps_its_native_type() {
356 let ty = FieldType::Extended(ExtKind::LocalDate);
357 assert_eq!(
358 ty.coerce("1979-05-27"),
359 Value::Extended {
360 kind: ExtKind::LocalDate,
361 text: "1979-05-27".into(),
362 }
363 );
364 // Text that can't be a date literal would emit an unquoted, unparseable
365 // token, so it falls back to a string like any other bad coercion.
366 assert_eq!(ty.coerce("not a date"), Value::Str("not a date".into()));
367 assert_eq!(ty.coerce(""), Value::Str("".into()));
368 }
369
370 #[test]
371 fn extended_shape_guard_covers_every_kind() {
372 assert!(extended_text_fits(
373 ExtKind::OffsetDateTime,
374 "1979-05-27T07:32:00Z"
375 ));
376 assert!(extended_text_fits(ExtKind::LocalTime, "07:32:00.999"));
377 assert!(extended_text_fits(ExtKind::EnumLiteral, "foo_bar"));
378 assert!(!extended_text_fits(ExtKind::EnumLiteral, "9lives"));
379 assert!(!extended_text_fits(ExtKind::EnumLiteral, "has space"));
380 assert!(extended_text_fits(ExtKind::CharLiteral, "97"));
381 assert!(!extended_text_fits(ExtKind::CharLiteral, "a"));
382 assert!(extended_text_fits(ExtKind::NumberSpecial, "-Infinity"));
383 assert!(!extended_text_fits(ExtKind::NumberSpecial, "inf"));
384 }
385
386 // A minimal `Validate` impl exercises the generic engine end to end without
387 // pulling in a real embedder's constraint type.
388 #[derive(Debug, Clone)]
389 struct AlwaysReject;
390 impl Validate for AlwaysReject {
391 fn validate(&self, _value: &Value) -> Validation {
392 Validation::Reject(Issue::custom("", "no"))
393 }
394 }
395
396 #[test]
397 fn rule_validate_dispatches_to_the_embedder_constraint() {
398 let rule = FieldRule::new(PathPat::key("status"))
399 .ty(FieldType::Str)
400 .constraint(AlwaysReject);
401 assert!(rule.validate(&Value::Str("anything".into())).is_reject());
402 }
403
404 #[test]
405 fn rule_with_no_constraint_always_validates_ok() {
406 let rule: FieldRule<AlwaysReject> = FieldRule::new(PathPat::key("status"));
407 assert_eq!(
408 rule.validate(&Value::Str("anything".into())),
409 Validation::Ok
410 );
411 }
412
413 #[test]
414 fn schema_rule_for_finds_first_match_in_declaration_order() {
415 let schema = Schema::new(vec![
416 FieldRule::new(PathPat::each_item_of("tags"))
417 .ty(FieldType::Str)
418 .constraint_opt(None::<AlwaysReject>),
419 FieldRule::new(PathPat::key("title")).ty(FieldType::Str),
420 ]);
421 assert!(schema.rule_for(&[Seg::Key("title".into())]).is_some());
422 assert!(
423 schema
424 .rule_for(&[Seg::Key("tags".into()), Seg::Index(0)])
425 .is_some()
426 );
427 assert!(schema.rule_for(&[Seg::Key("missing".into())]).is_none());
428 }
429
430 #[test]
431 fn a_specific_rule_takes_precedence_over_a_subtree_rule() {
432 let schema = Schema::new(vec![
433 FieldRule::new(PathPat(vec![
434 crate::SegPat::Key("meta".into()),
435 crate::SegPat::Key("id".into()),
436 ]))
437 .ty(FieldType::Int)
438 .constraint_opt(None::<AlwaysReject>),
439 FieldRule::new(PathPat::subtree_of("meta")).ty(FieldType::Str),
440 ]);
441 let id = [Seg::Key("meta".into()), Seg::Key("id".into())];
442 assert_eq!(schema.rule_for(&id).unwrap().ty, Some(FieldType::Int));
443 let other = [Seg::Key("meta".into()), Seg::Key("author".into())];
444 assert_eq!(schema.rule_for(&other).unwrap().ty, Some(FieldType::Str));
445 }
446}