Skip to main content

flyleaf_core/
kind.rs

1//! What a value is, what a new one starts as, and what one can become.
2//!
3//! TOML has eleven kinds once the four datetime shapes are told apart, and
4//! an editor that promises type-aware editing has to name all of them: a
5//! local date is not an offset date-time with the time left off, and a
6//! person who typed one meant it.
7
8use toml_edit::{Array, Datetime, InlineTable, Item, Table, Value};
9
10/// A kind of value, or a table.
11#[derive(Clone, Copy, PartialEq, Eq, Debug)]
12pub enum Kind {
13    /// A string.
14    Text,
15    /// An integer.
16    Integer,
17    /// A float.
18    Float,
19    /// A boolean.
20    Boolean,
21    /// A date and time with an offset from UTC.
22    OffsetDateTime,
23    /// A date and time with no offset.
24    LocalDateTime,
25    /// A date alone.
26    LocalDate,
27    /// A time alone.
28    LocalTime,
29    /// An array of values.
30    Array,
31    /// A table written on one line, `{ a = 1 }`.
32    InlineTable,
33    /// A table with a header, `[name]`.
34    Table,
35}
36
37impl Kind {
38    /// The kinds that are a single value.
39    pub const SCALARS: [Self; 8] = [
40        Self::Text,
41        Self::Integer,
42        Self::Float,
43        Self::Boolean,
44        Self::OffsetDateTime,
45        Self::LocalDateTime,
46        Self::LocalDate,
47        Self::LocalTime,
48    ];
49
50    /// The kinds an inline table or an array can hold: every value.
51    pub const VALUES: [Self; 10] = [
52        Self::Text,
53        Self::Integer,
54        Self::Float,
55        Self::Boolean,
56        Self::OffsetDateTime,
57        Self::LocalDateTime,
58        Self::LocalDate,
59        Self::LocalTime,
60        Self::Array,
61        Self::InlineTable,
62    ];
63
64    /// Every kind, in the order a picker offers them.
65    pub const ALL: [Self; 11] = [
66        Self::Text,
67        Self::Integer,
68        Self::Float,
69        Self::Boolean,
70        Self::OffsetDateTime,
71        Self::LocalDateTime,
72        Self::LocalDate,
73        Self::LocalTime,
74        Self::Array,
75        Self::InlineTable,
76        Self::Table,
77    ];
78
79    /// What it is called where somebody chooses it, or reads it beside a row.
80    #[must_use]
81    pub fn label(self) -> &'static str {
82        match self {
83            Self::Text => "text",
84            Self::Integer => "integer",
85            Self::Float => "float",
86            Self::Boolean => "boolean",
87            Self::OffsetDateTime => "offset date-time",
88            Self::LocalDateTime => "local date-time",
89            Self::LocalDate => "local date",
90            Self::LocalTime => "local time",
91            Self::Array => "array",
92            Self::InlineTable => "inline table",
93            Self::Table => "table",
94        }
95    }
96
97    /// The kind of a value.
98    #[must_use]
99    pub fn of_value(v: &Value) -> Self {
100        match v {
101            Value::String(_) => Self::Text,
102            Value::Integer(_) => Self::Integer,
103            Value::Float(_) => Self::Float,
104            Value::Boolean(_) => Self::Boolean,
105            Value::Datetime(d) => Self::of_datetime(d.value()),
106            Value::Array(_) => Self::Array,
107            Value::InlineTable(_) => Self::InlineTable,
108        }
109    }
110
111    /// The kind of an item; `None` for a removed one and for an array of
112    /// tables, which is not a value and not one table.
113    #[must_use]
114    pub fn of_item(item: &Item) -> Option<Self> {
115        match item {
116            Item::Value(v) => Some(Self::of_value(v)),
117            Item::Table(_) => Some(Self::Table),
118            Item::None | Item::ArrayOfTables(_) => None,
119        }
120    }
121
122    fn of_datetime(d: &Datetime) -> Self {
123        match (d.date.is_some(), d.time.is_some(), d.offset.is_some()) {
124            (true, true, true) => Self::OffsetDateTime,
125            (true, true, false) => Self::LocalDateTime,
126            (true, false, _) => Self::LocalDate,
127            (false, _, _) => Self::LocalTime,
128        }
129    }
130
131    /// What a new one starts as, where only a value will do.
132    #[must_use]
133    pub fn as_value(self) -> Option<Value> {
134        match self.item() {
135            Item::Value(v) => Some(v),
136            _ => None,
137        }
138    }
139
140    /// What a new one starts as: empty, zero, false, or the epoch, which is a
141    /// date somebody will replace rather than a guess at the one they meant.
142    ///
143    /// # Panics
144    ///
145    /// Never: the four datetime literals are fixed and parse, and a test
146    /// makes every kind once.
147    #[must_use]
148    pub fn item(self) -> Item {
149        let datetime = |text: &str| {
150            Item::Value(Value::from(
151                text.parse::<Datetime>().expect("a fixed datetime literal"),
152            ))
153        };
154        match self {
155            Self::Text => Item::Value(Value::from("")),
156            Self::Integer => Item::Value(Value::from(0_i64)),
157            Self::Float => Item::Value(Value::from(0.0_f64)),
158            Self::Boolean => Item::Value(Value::from(false)),
159            Self::OffsetDateTime => datetime("1970-01-01T00:00:00Z"),
160            Self::LocalDateTime => datetime("1970-01-01T00:00:00"),
161            Self::LocalDate => datetime("1970-01-01"),
162            Self::LocalTime => datetime("00:00:00"),
163            Self::Array => Item::Value(Value::Array(Array::new())),
164            Self::InlineTable => Item::Value(Value::InlineTable(InlineTable::new())),
165            Self::Table => Item::Table(Table::new()),
166        }
167    }
168}
169
170/// The value as another kind, where it reads as one; `None` where the change
171/// would be a guess.
172///
173/// What converts: a scalar to text, always, as the text it is written with;
174/// text to anything it parses as; an integer to a float and a whole float
175/// back; a datetime to another shape by dropping what the target lacks, or
176/// filling what it lacks with midnight, the epoch, or UTC; any value to a
177/// one-element array, and a one-element array back to its element. What does
178/// not: a boolean to a number and back, since `true` is not `1` in TOML; a
179/// float with a fraction to an integer; anything to an inline table. The
180/// returned value carries no decor; [`crate::set_value`] keeps the old one's.
181#[must_use]
182pub fn convert(v: &Value, to: Kind) -> Option<Value> {
183    if Kind::of_value(v) == to {
184        return Some(v.clone());
185    }
186    match to {
187        Kind::Text => Some(Value::from(scalar_text(v)?)),
188        Kind::Integer => match v {
189            Value::String(s) => s.value().trim().parse::<i64>().ok().map(Value::from),
190            Value::Float(f) if f.value().fract() == 0.0 && f.value().is_finite() => {
191                // Whole, finite, and within range: a float past i64 saturates
192                // silently on `as`, so the range is checked first, and what
193                // the lint calls truncation cannot happen to a whole number.
194                let x = *f.value();
195                #[allow(clippy::cast_possible_truncation)]
196                (x.abs() < 9.2e18).then(|| Value::from(x as i64))
197            }
198            Value::Array(a) => single(a).and_then(|e| convert(e, to)),
199            _ => None,
200        },
201        Kind::Float => match v {
202            Value::String(s) => s.value().trim().parse::<f64>().ok().map(Value::from),
203            // Precision is lost past 2^53, which is the conversion asked for
204            // and what `as` does.
205            #[allow(clippy::cast_precision_loss)]
206            Value::Integer(i) => Some(Value::from(*i.value() as f64)),
207            Value::Array(a) => single(a).and_then(|e| convert(e, to)),
208            _ => None,
209        },
210        Kind::Boolean => match v {
211            Value::String(s) => match s.value().trim() {
212                "true" => Some(Value::from(true)),
213                "false" => Some(Value::from(false)),
214                _ => None,
215            },
216            Value::Array(a) => single(a).and_then(|e| convert(e, to)),
217            _ => None,
218        },
219        Kind::OffsetDateTime | Kind::LocalDateTime | Kind::LocalDate | Kind::LocalTime => {
220            let d = match v {
221                Value::String(s) => s.value().trim().parse::<Datetime>().ok()?,
222                Value::Datetime(d) => *d.value(),
223                Value::Array(a) => return single(a).and_then(|e| convert(e, to)),
224                _ => return None,
225            };
226            Some(Value::from(reshape(d, to)))
227        }
228        Kind::Array => {
229            // The value's own decor is the space before it on its line, which
230            // inside brackets would read `[ 44]`.
231            let mut element = v.clone();
232            *element.decor_mut() = toml_edit::Decor::default();
233            let mut a = Array::new();
234            a.push_formatted(element);
235            Some(Value::Array(a))
236        }
237        Kind::InlineTable | Kind::Table => None,
238    }
239}
240
241/// A scalar as text: the string itself, or the text a number, boolean or
242/// datetime is written with.
243fn scalar_text(v: &Value) -> Option<String> {
244    match v {
245        Value::String(s) => Some(s.value().clone()),
246        Value::Integer(i) => Some(i.value().to_string()),
247        Value::Float(f) => Some(f.value().to_string()),
248        Value::Boolean(b) => Some(b.value().to_string()),
249        Value::Datetime(d) => Some(d.value().to_string()),
250        Value::Array(a) => single(a).and_then(scalar_text),
251        Value::InlineTable(_) => None,
252    }
253}
254
255/// The one element of a one-element array.
256fn single(a: &Array) -> Option<&Value> {
257    (a.len() == 1).then(|| a.get(0)).flatten()
258}
259
260/// A datetime as another shape: what the target lacks is dropped, and what
261/// it has that the source lacks is filled with the epoch's date, midnight, or
262/// UTC.
263fn reshape(d: Datetime, to: Kind) -> Datetime {
264    let epoch_date = "1970-01-01".parse::<Datetime>().expect("a date").date;
265    let midnight = "00:00:00".parse::<Datetime>().expect("a time").time;
266    let utc = "1970-01-01T00:00:00Z"
267        .parse::<Datetime>()
268        .expect("an offset")
269        .offset;
270    let date = d.date.or(epoch_date);
271    let time = d.time.or(midnight);
272    match to {
273        Kind::OffsetDateTime => Datetime {
274            date,
275            time,
276            offset: d.offset.or(utc),
277        },
278        Kind::LocalDateTime => Datetime {
279            date,
280            time,
281            offset: None,
282        },
283        Kind::LocalDate => Datetime {
284            date,
285            time: None,
286            offset: None,
287        },
288        _ => Datetime {
289            date: None,
290            time,
291            offset: None,
292        },
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{convert, Kind};
299    use toml_edit::{DocumentMut, Value};
300
301    fn value(text: &str) -> Value {
302        let doc: DocumentMut = format!("v = {text}\n").parse().expect("valid TOML");
303        doc["v"].as_value().expect("a value").clone()
304    }
305
306    fn text_of(v: &Value) -> String {
307        v.to_string().trim().to_owned()
308    }
309
310    /// Every datetime shape is told apart, because a local date is not an
311    /// offset date-time with the time left off.
312    #[test]
313    fn the_four_datetime_shapes_are_told_apart() {
314        assert_eq!(
315            Kind::of_value(&value("1979-05-27T07:32:00Z")),
316            Kind::OffsetDateTime
317        );
318        assert_eq!(
319            Kind::of_value(&value("1979-05-27T07:32:00")),
320            Kind::LocalDateTime
321        );
322        assert_eq!(Kind::of_value(&value("1979-05-27")), Kind::LocalDate);
323        assert_eq!(Kind::of_value(&value("07:32:00")), Kind::LocalTime);
324    }
325
326    /// What a new value of each kind starts as is that kind, and is valid
327    /// TOML: a picker that offered a kind whose starting value was another
328    /// would lie.
329    #[test]
330    fn every_kind_starts_as_itself() {
331        for kind in Kind::ALL {
332            let item = kind.item();
333            assert_eq!(Kind::of_item(&item), Some(kind), "{kind:?}");
334            let mut doc = DocumentMut::new();
335            doc.as_table_mut().insert("k", item);
336            doc.to_string().parse::<DocumentMut>().expect("valid TOML");
337        }
338    }
339
340    /// The conversions that read as the target, and what they produce.
341    #[test]
342    fn conversions_that_read_as_the_target_succeed() {
343        let cases: &[(&str, Kind, &str)] = &[
344            ("44", Kind::Text, "\"44\""),
345            ("1.5", Kind::Text, "\"1.5\""),
346            ("true", Kind::Text, "\"true\""),
347            ("1979-05-27", Kind::Text, "\"1979-05-27\""),
348            ("\"44\"", Kind::Integer, "44"),
349            ("\" 44 \"", Kind::Integer, "44"),
350            ("2.0", Kind::Integer, "2"),
351            ("\"1.5\"", Kind::Float, "1.5"),
352            ("2", Kind::Float, "2.0"),
353            ("\"true\"", Kind::Boolean, "true"),
354            ("\"1979-05-27\"", Kind::LocalDate, "1979-05-27"),
355            (
356                "1979-05-27T07:32:00Z",
357                Kind::LocalDateTime,
358                "1979-05-27T07:32:00",
359            ),
360            ("1979-05-27T07:32:00Z", Kind::LocalDate, "1979-05-27"),
361            ("1979-05-27T07:32:00Z", Kind::LocalTime, "07:32:00"),
362            (
363                "1979-05-27T07:32:00",
364                Kind::OffsetDateTime,
365                "1979-05-27T07:32:00Z",
366            ),
367            ("1979-05-27", Kind::LocalDateTime, "1979-05-27T00:00:00"),
368            ("07:32:00", Kind::LocalDateTime, "1970-01-01T07:32:00"),
369            ("44", Kind::Array, "[44]"),
370            ("[44]", Kind::Integer, "44"),
371            ("[\"x\"]", Kind::Text, "\"x\""),
372            ("44", Kind::Integer, "44"),
373        ];
374        for (from, to, expected) in cases {
375            let got = convert(&value(from), *to)
376                .unwrap_or_else(|| panic!("{from} -> {to:?} was refused"));
377            assert_eq!(text_of(&got), *expected, "{from} -> {to:?}");
378            assert_eq!(
379                Kind::of_value(&got),
380                *to,
381                "{from} -> {to:?} is not a {to:?}"
382            );
383        }
384    }
385
386    /// The conversions that would be a guess are refused rather than guessed.
387    #[test]
388    fn conversions_that_would_guess_are_refused() {
389        let cases: &[(&str, Kind)] = &[
390            ("true", Kind::Integer),
391            ("1", Kind::Boolean),
392            ("1.5", Kind::Integer),
393            ("\"forty-four\"", Kind::Integer),
394            ("\"yes\"", Kind::Boolean),
395            ("\"tomorrow\"", Kind::LocalDate),
396            ("1979-05-27", Kind::Integer),
397            ("[1, 2]", Kind::Integer),
398            ("44", Kind::InlineTable),
399            ("{ a = 1 }", Kind::Text),
400            ("inf", Kind::Integer),
401            ("1e300", Kind::Integer),
402        ];
403        for (from, to) in cases {
404            assert!(
405                convert(&value(from), *to).is_none(),
406                "{from} -> {to:?} was allowed"
407            );
408        }
409    }
410}