Skip to main content

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}