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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FieldType {
17    Null,
18    Bool,
19    Int,
20    Float,
21    Str,
22    /// A link into the workspace (stored textually, like `Str`, but a reference).
23    Ref,
24    /// A format-specific scalar carried verbatim — a TOML datetime, a ZON enum
25    /// or char literal. Coercing to one keeps the value's native type instead
26    /// of quoting it into a string, so a TOML `date = 1979-05-27` survives an
27    /// edit as a date rather than becoming `date = "1979-05-27"`.
28    Extended(ExtKind),
29    Map,
30    Seq,
31}
32
33impl FieldType {
34    /// Coerce an edit-buffer string to this type — the schema-directed
35    /// counterpart of shape-guessing. A value that doesn't fit the type falls
36    /// back to a string (the caller's own reparse is the final backstop);
37    /// container types are not scalar-edited, so they also pass through as text.
38    ///
39    /// Note that `Float` accepts the non-finite spellings Rust's parser does
40    /// (`inf`, `NaN`). fig renders those as YAML's `.inf`/`.nan`, but they have
41    /// no representation in JSON or TOML — an embedder targeting those formats
42    /// should reject them before they reach here.
43    pub fn coerce(self, s: &str) -> Value {
44        let t = s.trim();
45        match self {
46            // Only the null spellings mean null; anything else is real text the
47            // user typed, and silently dropping it would lose their edit.
48            FieldType::Null => match t {
49                "" | "~" => Value::Null,
50                _ if t.eq_ignore_ascii_case("null") => Value::Null,
51                _ => Value::Str(s.to_string()),
52            },
53            // The YAML 1.1 spellings are all accepted: the field is *declared*
54            // a bool, so `yes`/`on` are unambiguous here — the "Norway problem"
55            // is a hazard of untyped inference, which is exactly what a schema
56            // replaces. The coerced value is canonical either way.
57            FieldType::Bool => match t.to_ascii_lowercase().as_str() {
58                "true" | "yes" | "on" => Value::Bool(true),
59                "false" | "no" | "off" => Value::Bool(false),
60                _ => Value::Str(s.to_string()),
61            },
62            FieldType::Int => t
63                .parse::<i64>()
64                .map(Value::Int)
65                .or_else(|_| t.parse::<u64>().map(Value::Uint))
66                .unwrap_or_else(|_| Value::Str(s.to_string())),
67            FieldType::Float => t
68                .parse::<f64>()
69                .map(Value::Float)
70                .unwrap_or_else(|_| Value::Str(s.to_string())),
71            FieldType::Extended(kind) => {
72                if extended_text_fits(kind, t) {
73                    Value::Extended {
74                        kind,
75                        text: t.to_string(),
76                    }
77                } else {
78                    Value::Str(s.to_string())
79                }
80            }
81            // A string/ref field keeps its literal text — the whole point of
82            // type-directed parsing: `"123"` in a `str` field stays a string.
83            FieldType::Str | FieldType::Ref | FieldType::Map | FieldType::Seq => {
84                Value::Str(s.to_string())
85            }
86        }
87    }
88}
89
90/// Whether `text` is shaped like a literal of `kind`.
91///
92/// A [`Value::Extended`] is printed verbatim and *unquoted*, so garbage here
93/// would emit a document the format can't reparse (`date = not a date`). This
94/// is a cheap shape guard, not a parser: it rejects what obviously can't be a
95/// literal and leaves the rest to the format's own reader.
96fn extended_text_fits(kind: ExtKind, text: &str) -> bool {
97    if text.is_empty() {
98        return false;
99    }
100    match kind {
101        // Digits and the punctuation that separates them.
102        ExtKind::OffsetDateTime
103        | ExtKind::LocalDateTime
104        | ExtKind::LocalDate
105        | ExtKind::LocalTime => text.chars().all(|c| {
106            c.is_ascii_digit() || matches!(c, '-' | ':' | '.' | '+' | 'T' | 't' | 'Z' | 'z' | ' ')
107        }),
108        // A bare identifier — the text excludes the leading dot.
109        ExtKind::EnumLiteral => {
110            let mut chars = text.chars();
111            chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
112                && chars.all(|c| c.is_alphanumeric() || c == '_')
113        }
114        // Stored as a decimal codepoint.
115        ExtKind::CharLiteral => text.chars().all(|c| c.is_ascii_digit()),
116        ExtKind::NumberSpecial => matches!(
117            text,
118            "Infinity" | "-Infinity" | "+Infinity" | "NaN" | "-NaN" | "+NaN"
119        ),
120        // `ExtKind` is `#[non_exhaustive]`: a fig version newer than this crate
121        // may add a kind we don't recognize yet. This is only a cheap shape
122        // guard (see the doc comment above), so defer to the format's own
123        // reader rather than reject a literal we simply don't have a rule for.
124        _ => true,
125    }
126}
127
128/// One field rule: which node(s) it governs, the type it expects, an optional
129/// constraint of the embedder's own type `C`, and how to present it.
130#[derive(Debug, Clone)]
131pub struct FieldRule<C> {
132    /// Which node(s) this governs (reaches list *elements*, not only scalars).
133    pub at: PathPat,
134    /// The expected type — drives type-directed parsing and widget choice.
135    pub ty: Option<FieldType>,
136    /// A value constraint, in whatever shape the embedder defines.
137    pub constraint: Option<C>,
138    /// Renderer-neutral presentation hints.
139    pub present: Presentation,
140}
141
142impl<C: Validate> FieldRule<C> {
143    /// Validate a candidate `value` against this rule's constraint. A rule with
144    /// no constraint (or a type-only rule) imposes nothing here.
145    pub fn validate(&self, value: &Value) -> Validation {
146        match &self.constraint {
147            Some(c) => c.validate(value),
148            None => Validation::Ok,
149        }
150    }
151}
152
153/// A set of field rules. Matched against a row's fig path to find what governs
154/// it.
155#[derive(Debug, Clone)]
156pub struct Schema<C> {
157    rules: Vec<FieldRule<C>>,
158}
159
160impl<C> Default for Schema<C> {
161    fn default() -> Self {
162        Self { rules: Vec::new() }
163    }
164}
165
166impl<C> Schema<C> {
167    /// Build a schema from its rules.
168    pub fn new(rules: Vec<FieldRule<C>>) -> Self {
169        Self { rules }
170    }
171
172    /// The rules, in declaration order.
173    pub fn rules(&self) -> &[FieldRule<C>] {
174        &self.rules
175    }
176
177    /// Whether the schema carries no rules (nothing to apply).
178    pub fn is_empty(&self) -> bool {
179        self.rules.is_empty()
180    }
181
182    /// The first rule whose pattern matches `path`, if any. Declaration order is
183    /// precedence, so a more specific rule should be listed before a broader one.
184    pub fn rule_for(&self, path: &[Seg]) -> Option<&FieldRule<C>> {
185        self.rules.iter().find(|r| r.at.matches(path))
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::vocab::Issue;
193
194    #[test]
195    fn type_directed_parse_keeps_a_string_field_a_string() {
196        assert_eq!(FieldType::Str.coerce("123"), Value::Str("123".into()));
197        assert_eq!(FieldType::Int.coerce("123"), Value::Int(123));
198        assert_eq!(FieldType::Bool.coerce("true"), Value::Bool(true));
199        // A non-fitting value falls back to a string (reparse is the backstop).
200        assert_eq!(FieldType::Int.coerce("abc"), Value::Str("abc".into()));
201    }
202
203    #[test]
204    fn a_null_field_keeps_text_it_cannot_read_as_null() {
205        assert_eq!(FieldType::Null.coerce(""), Value::Null);
206        assert_eq!(FieldType::Null.coerce("null"), Value::Null);
207        assert_eq!(FieldType::Null.coerce("NULL"), Value::Null);
208        assert_eq!(FieldType::Null.coerce("~"), Value::Null);
209        // Anything else is a real edit, and must not be silently dropped.
210        assert_eq!(
211            FieldType::Null.coerce("important data"),
212            Value::Str("important data".into())
213        );
214    }
215
216    #[test]
217    fn a_bool_field_accepts_the_yaml_spellings() {
218        for yes in ["true", "True", "TRUE", "yes", "Yes", "on"] {
219            assert_eq!(FieldType::Bool.coerce(yes), Value::Bool(true), "{yes}");
220        }
221        for no in ["false", "False", "FALSE", "no", "No", "off"] {
222            assert_eq!(FieldType::Bool.coerce(no), Value::Bool(false), "{no}");
223        }
224        assert_eq!(FieldType::Bool.coerce("maybe"), Value::Str("maybe".into()));
225    }
226
227    #[test]
228    fn an_extended_field_keeps_its_native_type() {
229        let ty = FieldType::Extended(ExtKind::LocalDate);
230        assert_eq!(
231            ty.coerce("1979-05-27"),
232            Value::Extended {
233                kind: ExtKind::LocalDate,
234                text: "1979-05-27".into(),
235            }
236        );
237        // Text that can't be a date literal would emit an unquoted, unparseable
238        // token, so it falls back to a string like any other bad coercion.
239        assert_eq!(ty.coerce("not a date"), Value::Str("not a date".into()));
240        assert_eq!(ty.coerce(""), Value::Str("".into()));
241    }
242
243    #[test]
244    fn extended_shape_guard_covers_every_kind() {
245        assert!(extended_text_fits(
246            ExtKind::OffsetDateTime,
247            "1979-05-27T07:32:00Z"
248        ));
249        assert!(extended_text_fits(ExtKind::LocalTime, "07:32:00.999"));
250        assert!(extended_text_fits(ExtKind::EnumLiteral, "foo_bar"));
251        assert!(!extended_text_fits(ExtKind::EnumLiteral, "9lives"));
252        assert!(!extended_text_fits(ExtKind::EnumLiteral, "has space"));
253        assert!(extended_text_fits(ExtKind::CharLiteral, "97"));
254        assert!(!extended_text_fits(ExtKind::CharLiteral, "a"));
255        assert!(extended_text_fits(ExtKind::NumberSpecial, "-Infinity"));
256        assert!(!extended_text_fits(ExtKind::NumberSpecial, "inf"));
257    }
258
259    // A minimal `Validate` impl exercises the generic engine end to end without
260    // pulling in a real embedder's constraint type.
261    #[derive(Debug, Clone)]
262    struct AlwaysReject;
263    impl Validate for AlwaysReject {
264        fn validate(&self, _value: &Value) -> Validation {
265            Validation::Reject(Issue::custom("", "no"))
266        }
267    }
268
269    #[test]
270    fn rule_validate_dispatches_to_the_embedder_constraint() {
271        let rule = FieldRule {
272            at: PathPat::key("status"),
273            ty: Some(FieldType::Str),
274            constraint: Some(AlwaysReject),
275            present: Presentation::default(),
276        };
277        assert!(rule.validate(&Value::Str("anything".into())).is_reject());
278    }
279
280    #[test]
281    fn rule_with_no_constraint_always_validates_ok() {
282        let rule: FieldRule<AlwaysReject> = FieldRule {
283            at: PathPat::key("status"),
284            ty: None,
285            constraint: None,
286            present: Presentation::default(),
287        };
288        assert_eq!(
289            rule.validate(&Value::Str("anything".into())),
290            Validation::Ok
291        );
292    }
293
294    #[test]
295    fn schema_rule_for_finds_first_match_in_declaration_order() {
296        let schema = Schema::new(vec![
297            FieldRule {
298                at: PathPat::each_item_of("tags"),
299                ty: Some(FieldType::Str),
300                constraint: None::<AlwaysReject>,
301                present: Presentation::default(),
302            },
303            FieldRule {
304                at: PathPat::key("title"),
305                ty: Some(FieldType::Str),
306                constraint: None,
307                present: Presentation::default(),
308            },
309        ]);
310        assert!(schema.rule_for(&[Seg::Key("title".into())]).is_some());
311        assert!(
312            schema
313                .rule_for(&[Seg::Key("tags".into()), Seg::Index(0)])
314                .is_some()
315        );
316        assert!(schema.rule_for(&[Seg::Key("missing".into())]).is_none());
317    }
318
319    #[test]
320    fn a_specific_rule_takes_precedence_over_a_subtree_rule() {
321        let schema = Schema::new(vec![
322            FieldRule {
323                at: PathPat(vec![
324                    crate::SegPat::Key("meta".into()),
325                    crate::SegPat::Key("id".into()),
326                ]),
327                ty: Some(FieldType::Int),
328                constraint: None::<AlwaysReject>,
329                present: Presentation::default(),
330            },
331            FieldRule {
332                at: PathPat::subtree_of("meta"),
333                ty: Some(FieldType::Str),
334                constraint: None,
335                present: Presentation::default(),
336            },
337        ]);
338        let id = [Seg::Key("meta".into()), Seg::Key("id".into())];
339        assert_eq!(schema.rule_for(&id).unwrap().ty, Some(FieldType::Int));
340        let other = [Seg::Key("meta".into()), Seg::Key("author".into())];
341        assert_eq!(schema.rule_for(&other).unwrap().ty, Some(FieldType::Str));
342    }
343}