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::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}