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    }
121}
122
123/// One field rule: which node(s) it governs, the type it expects, an optional
124/// constraint of the embedder's own type `C`, and how to present it.
125#[derive(Debug, Clone)]
126pub struct FieldRule<C> {
127    /// Which node(s) this governs (reaches list *elements*, not only scalars).
128    pub at: PathPat,
129    /// The expected type — drives type-directed parsing and widget choice.
130    pub ty: Option<FieldType>,
131    /// A value constraint, in whatever shape the embedder defines.
132    pub constraint: Option<C>,
133    /// Renderer-neutral presentation hints.
134    pub present: Presentation,
135}
136
137impl<C: Validate> FieldRule<C> {
138    /// Validate a candidate `value` against this rule's constraint. A rule with
139    /// no constraint (or a type-only rule) imposes nothing here.
140    pub fn validate(&self, value: &Value) -> Validation {
141        match &self.constraint {
142            Some(c) => c.validate(value),
143            None => Validation::Ok,
144        }
145    }
146}
147
148/// A set of field rules. Matched against a row's fig path to find what governs
149/// it.
150#[derive(Debug, Clone)]
151pub struct Schema<C> {
152    rules: Vec<FieldRule<C>>,
153}
154
155impl<C> Default for Schema<C> {
156    fn default() -> Self {
157        Self { rules: Vec::new() }
158    }
159}
160
161impl<C> Schema<C> {
162    /// Build a schema from its rules.
163    pub fn new(rules: Vec<FieldRule<C>>) -> Self {
164        Self { rules }
165    }
166
167    /// The rules, in declaration order.
168    pub fn rules(&self) -> &[FieldRule<C>] {
169        &self.rules
170    }
171
172    /// Whether the schema carries no rules (nothing to apply).
173    pub fn is_empty(&self) -> bool {
174        self.rules.is_empty()
175    }
176
177    /// The first rule whose pattern matches `path`, if any. Declaration order is
178    /// precedence, so a more specific rule should be listed before a broader one.
179    pub fn rule_for(&self, path: &[Seg]) -> Option<&FieldRule<C>> {
180        self.rules.iter().find(|r| r.at.matches(path))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::vocab::Issue;
188
189    #[test]
190    fn type_directed_parse_keeps_a_string_field_a_string() {
191        assert_eq!(FieldType::Str.coerce("123"), Value::Str("123".into()));
192        assert_eq!(FieldType::Int.coerce("123"), Value::Int(123));
193        assert_eq!(FieldType::Bool.coerce("true"), Value::Bool(true));
194        // A non-fitting value falls back to a string (reparse is the backstop).
195        assert_eq!(FieldType::Int.coerce("abc"), Value::Str("abc".into()));
196    }
197
198    #[test]
199    fn a_null_field_keeps_text_it_cannot_read_as_null() {
200        assert_eq!(FieldType::Null.coerce(""), Value::Null);
201        assert_eq!(FieldType::Null.coerce("null"), Value::Null);
202        assert_eq!(FieldType::Null.coerce("NULL"), Value::Null);
203        assert_eq!(FieldType::Null.coerce("~"), Value::Null);
204        // Anything else is a real edit, and must not be silently dropped.
205        assert_eq!(
206            FieldType::Null.coerce("important data"),
207            Value::Str("important data".into())
208        );
209    }
210
211    #[test]
212    fn a_bool_field_accepts_the_yaml_spellings() {
213        for yes in ["true", "True", "TRUE", "yes", "Yes", "on"] {
214            assert_eq!(FieldType::Bool.coerce(yes), Value::Bool(true), "{yes}");
215        }
216        for no in ["false", "False", "FALSE", "no", "No", "off"] {
217            assert_eq!(FieldType::Bool.coerce(no), Value::Bool(false), "{no}");
218        }
219        assert_eq!(FieldType::Bool.coerce("maybe"), Value::Str("maybe".into()));
220    }
221
222    #[test]
223    fn an_extended_field_keeps_its_native_type() {
224        let ty = FieldType::Extended(ExtKind::LocalDate);
225        assert_eq!(
226            ty.coerce("1979-05-27"),
227            Value::Extended {
228                kind: ExtKind::LocalDate,
229                text: "1979-05-27".into(),
230            }
231        );
232        // Text that can't be a date literal would emit an unquoted, unparseable
233        // token, so it falls back to a string like any other bad coercion.
234        assert_eq!(ty.coerce("not a date"), Value::Str("not a date".into()));
235        assert_eq!(ty.coerce(""), Value::Str("".into()));
236    }
237
238    #[test]
239    fn extended_shape_guard_covers_every_kind() {
240        assert!(extended_text_fits(
241            ExtKind::OffsetDateTime,
242            "1979-05-27T07:32:00Z"
243        ));
244        assert!(extended_text_fits(ExtKind::LocalTime, "07:32:00.999"));
245        assert!(extended_text_fits(ExtKind::EnumLiteral, "foo_bar"));
246        assert!(!extended_text_fits(ExtKind::EnumLiteral, "9lives"));
247        assert!(!extended_text_fits(ExtKind::EnumLiteral, "has space"));
248        assert!(extended_text_fits(ExtKind::CharLiteral, "97"));
249        assert!(!extended_text_fits(ExtKind::CharLiteral, "a"));
250        assert!(extended_text_fits(ExtKind::NumberSpecial, "-Infinity"));
251        assert!(!extended_text_fits(ExtKind::NumberSpecial, "inf"));
252    }
253
254    // A minimal `Validate` impl exercises the generic engine end to end without
255    // pulling in a real embedder's constraint type.
256    #[derive(Debug, Clone)]
257    struct AlwaysReject;
258    impl Validate for AlwaysReject {
259        fn validate(&self, _value: &Value) -> Validation {
260            Validation::Reject(Issue::custom("", "no"))
261        }
262    }
263
264    #[test]
265    fn rule_validate_dispatches_to_the_embedder_constraint() {
266        let rule = FieldRule {
267            at: PathPat::key("status"),
268            ty: Some(FieldType::Str),
269            constraint: Some(AlwaysReject),
270            present: Presentation::default(),
271        };
272        assert!(rule.validate(&Value::Str("anything".into())).is_reject());
273    }
274
275    #[test]
276    fn rule_with_no_constraint_always_validates_ok() {
277        let rule: FieldRule<AlwaysReject> = FieldRule {
278            at: PathPat::key("status"),
279            ty: None,
280            constraint: None,
281            present: Presentation::default(),
282        };
283        assert_eq!(
284            rule.validate(&Value::Str("anything".into())),
285            Validation::Ok
286        );
287    }
288
289    #[test]
290    fn schema_rule_for_finds_first_match_in_declaration_order() {
291        let schema = Schema::new(vec![
292            FieldRule {
293                at: PathPat::each_item_of("tags"),
294                ty: Some(FieldType::Str),
295                constraint: None::<AlwaysReject>,
296                present: Presentation::default(),
297            },
298            FieldRule {
299                at: PathPat::key("title"),
300                ty: Some(FieldType::Str),
301                constraint: None,
302                present: Presentation::default(),
303            },
304        ]);
305        assert!(schema.rule_for(&[Seg::Key("title".into())]).is_some());
306        assert!(
307            schema
308                .rule_for(&[Seg::Key("tags".into()), Seg::Index(0)])
309                .is_some()
310        );
311        assert!(schema.rule_for(&[Seg::Key("missing".into())]).is_none());
312    }
313
314    #[test]
315    fn a_specific_rule_takes_precedence_over_a_subtree_rule() {
316        let schema = Schema::new(vec![
317            FieldRule {
318                at: PathPat(vec![
319                    crate::SegPat::Key("meta".into()),
320                    crate::SegPat::Key("id".into()),
321                ]),
322                ty: Some(FieldType::Int),
323                constraint: None::<AlwaysReject>,
324                present: Presentation::default(),
325            },
326            FieldRule {
327                at: PathPat::subtree_of("meta"),
328                ty: Some(FieldType::Str),
329                constraint: None,
330                present: Presentation::default(),
331            },
332        ]);
333        let id = [Seg::Key("meta".into()), Seg::Key("id".into())];
334        assert_eq!(schema.rule_for(&id).unwrap().ty, Some(FieldType::Int));
335        let other = [Seg::Key("meta".into()), Seg::Key("author".into())];
336        assert_eq!(schema.rule_for(&other).unwrap().ty, Some(FieldType::Str));
337    }
338}