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