Skip to main content

yaml_edit/scalar/
resolve.rs

1//! Core schema type resolution and timestamp detection.
2
3use super::decimal::signed_digits_to_decimal;
4use super::{CoreScalarType, ScalarType, ScalarValue};
5
6/// The non-string core schema type named by `tag`, if any.
7fn core_tag_type(tag: &str) -> Option<CoreScalarType> {
8    match crate::tag::yaml_org_name(tag)? {
9        "int" => Some(CoreScalarType::Integer),
10        "float" => Some(CoreScalarType::Float),
11        "bool" => Some(CoreScalarType::Boolean),
12        "null" => Some(CoreScalarType::Null),
13        _ => None,
14    }
15}
16
17/// Whether `value` is a valid spelling of a `t` under the core schema.
18fn core_tag_accepts(t: CoreScalarType, value: &str) -> bool {
19    match (t, ScalarValue::classify_plain(value)) {
20        (CoreScalarType::Null, _) if value.is_empty() => true,
21        // The core schema's float pattern also matches integers.
22        (CoreScalarType::Float, CoreScalarType::Integer) => true,
23        (t, c) => t == c,
24    }
25}
26
27/// Take `min..=max` ASCII digits from the front of `rest`, returning their
28/// value and how many there were.
29fn take_number(rest: &mut &[char], min: usize, max: usize) -> Option<(u32, usize)> {
30    let len = rest
31        .iter()
32        .take(max)
33        .take_while(|c| c.is_ascii_digit())
34        .count();
35    if len < min {
36        return None;
37    }
38    let value = rest[..len].iter().collect::<String>().parse().ok()?;
39    *rest = &rest[len..];
40    Some((value, len))
41}
42
43/// Take the first character of `rest` if `accept` accepts it.
44fn take_char(rest: &mut &[char], accept: impl Fn(char) -> bool) -> Option<char> {
45    let (&c, tail) = rest.split_first().filter(|(&c, _)| accept(c))?;
46    *rest = tail;
47    Some(c)
48}
49
50pub(super) fn all_digits(s: &str, radix: u32) -> bool {
51    !s.is_empty() && s.chars().all(|c| c.is_digit(radix))
52}
53
54impl ScalarValue {
55    /// Parse a YAML 1.2 core schema integer: `[-+]?[0-9]+` (decimal, even
56    /// with leading zeros), `0o[0-7]+` or `0x[0-9a-fA-F]+`.
57    pub(crate) fn parse_integer(value: &str) -> Option<i64> {
58        let (radix, digits, is_negative) = Self::split_core_int(value)?;
59        // Parse the magnitude wider than i64 so that i64::MIN fits.
60        let magnitude = i128::from_str_radix(digits, radix).ok()?;
61        i64::try_from(if is_negative { -magnitude } else { magnitude }).ok()
62    }
63
64    /// The decimal spelling of a YAML 1.2 core schema integer of any size,
65    /// without leading zeros, a `+` sign or a sign on zero, so that equal
66    /// values are spelled the same.
67    pub(crate) fn canonical_integer(value: &str) -> Option<String> {
68        let (radix, digits, is_negative) = Self::split_core_int(value)?;
69        Some(signed_digits_to_decimal(digits, radix, is_negative))
70    }
71
72    /// Split a YAML 1.2 core schema integer into its radix, its digits and
73    /// whether it is negative.
74    fn split_core_int(value: &str) -> Option<(u32, &str, bool)> {
75        if !Self::is_core_int(value) {
76            return None;
77        }
78        Some(if let Some(hex) = value.strip_prefix("0x") {
79            (16, hex, false)
80        } else if let Some(oct) = value.strip_prefix("0o") {
81            (8, oct, false)
82        } else if let Some(dec) = value.strip_prefix('-') {
83            (10, dec, true)
84        } else {
85            (10, value.strip_prefix('+').unwrap_or(value), false)
86        })
87    }
88
89    /// Parse a YAML 1.2 core schema float, including `.inf` and `.nan`.
90    pub(crate) fn parse_float(value: &str) -> Option<f64> {
91        match value {
92            ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => Some(f64::INFINITY),
93            "-.inf" | "-.Inf" | "-.INF" => Some(f64::NEG_INFINITY),
94            ".nan" | ".NaN" | ".NAN" => Some(f64::NAN),
95            _ if Self::is_core_float(value) => value.parse::<f64>().ok(),
96            _ => None,
97        }
98    }
99
100    /// Parse a YAML 1.2 core schema boolean.
101    pub(crate) fn parse_bool(value: &str) -> Option<bool> {
102        match value {
103            "true" | "True" | "TRUE" => Some(true),
104            "false" | "False" | "FALSE" => Some(false),
105            _ => None,
106        }
107    }
108
109    /// Whether `value` matches the YAML 1.2 core schema `!!int` regex:
110    /// `[-+]?[0-9]+`, `0o[0-7]+` or `0x[0-9a-fA-F]+`.
111    ///
112    /// This is a syntactic check, so it also holds for integers that do
113    /// not fit in an `i64`.
114    fn is_core_int(value: &str) -> bool {
115        if let Some(hex) = value.strip_prefix("0x") {
116            return all_digits(hex, 16);
117        }
118        if let Some(oct) = value.strip_prefix("0o") {
119            return all_digits(oct, 8);
120        }
121        all_digits(value.strip_prefix(['+', '-']).unwrap_or(value), 10)
122    }
123
124    /// Whether `value` matches the finite part of the YAML 1.2 core schema
125    /// `!!float` regex:
126    /// `[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?`.
127    pub(crate) fn is_core_float(value: &str) -> bool {
128        let body = value.strip_prefix(['+', '-']).unwrap_or(value);
129        let (mantissa, exponent) = match body.find(['e', 'E']) {
130            Some(i) => (&body[..i], Some(&body[i + 1..])),
131            None => (body, None),
132        };
133        let mantissa_ok = match mantissa.split_once('.') {
134            Some(("", frac)) => all_digits(frac, 10),
135            Some((int, frac)) => all_digits(int, 10) && frac.chars().all(|c| c.is_ascii_digit()),
136            None => all_digits(mantissa, 10),
137        };
138        let exponent_ok = match exponent {
139            Some(e) => all_digits(e.strip_prefix(['+', '-']).unwrap_or(e), 10),
140            None => true,
141        };
142        mantissa_ok && exponent_ok
143    }
144
145    /// Whether `value` is written in YAML 1.1's bare-octal form (a leading
146    /// `0` before more octal digits, as in `0755`).
147    ///
148    /// YAML 1.2's `!!int` has no such form: `0755` is seven hundred and
149    /// fifty five, where 1.1 read it as 493. `0o755` is the 1.2 spelling.
150    pub(crate) fn is_legacy_octal(value: &str) -> bool {
151        let body = value.strip_prefix(['+', '-']).unwrap_or(value);
152        body.len() > 1 && body.starts_with('0') && all_digits(body, 8)
153    }
154
155    /// Whether `value` is a decimal with a leading zero and an 8 or 9 (`089`).
156    ///
157    /// YAML 1.2 reads it as an integer. To YAML 1.1 it is a string: it is not
158    /// bare octal, and 1.1 decimal has no leading zero.
159    pub(crate) fn is_leading_zero_decimal(value: &str) -> bool {
160        let body = value.strip_prefix(['+', '-']).unwrap_or(value);
161        body.len() > 1 && body.starts_with('0') && all_digits(body, 10) && !all_digits(body, 8)
162    }
163
164    /// Classify a plain (unquoted) scalar's text per the YAML 1.2 core
165    /// schema tag-resolution rules.
166    ///
167    /// This is the single source of truth for what a plain scalar
168    /// resolves to; the lexer and [`from_scalar`](Self::from_scalar)
169    /// both delegate here so token-emit-time and CST-inspection-time
170    /// classification cannot drift.
171    ///
172    /// [`auto_detect_type`](Self::auto_detect_type) builds on this and
173    /// additionally recognises timestamps.
174    pub fn classify_plain(value: &str) -> CoreScalarType {
175        match value {
176            _ if Self::parse_bool(value).is_some() => return CoreScalarType::Boolean,
177            "null" | "Null" | "NULL" | "~" | "" => return CoreScalarType::Null,
178            _ => {}
179        }
180
181        if Self::is_core_int(value) {
182            return CoreScalarType::Integer;
183        }
184
185        // Unlike Rust's f64 parser, the core schema spells infinity and NaN
186        // only with a leading dot, and has no `inf`, `nan` or `infinity`.
187        if Self::parse_float(value).is_some() {
188            return CoreScalarType::Float;
189        }
190
191        CoreScalarType::String
192    }
193
194    /// Turn the `Display` output of an `f32`/`f64` into a YAML float.
195    ///
196    /// Maps `NaN`/`inf`/`-inf` to `.nan`/`.inf`/`-.inf` and adds `.0` to
197    /// integral values so they don't read back as integers.
198    pub(crate) fn format_float(display: String) -> String {
199        match display.as_str() {
200            "NaN" => ".nan".to_string(),
201            "inf" => ".inf".to_string(),
202            "-inf" => "-.inf".to_string(),
203            _ if display.contains(['.', 'e', 'E']) => display,
204            _ => format!("{display}.0"),
205        }
206    }
207
208    /// Auto-detect the most appropriate scalar type from a string value
209    ///
210    /// This follows [`classify_plain`](Self::classify_plain), except that
211    /// text the core schema reads as a string is checked for a timestamp.
212    pub fn auto_detect_type(value: &str) -> ScalarType {
213        match Self::classify_plain(value) {
214            CoreScalarType::String if Self::is_valid_timestamp(value) => ScalarType::Timestamp,
215            other => other.into(),
216        }
217    }
218
219    /// Create a ScalarValue from a tagged scalar node, keeping its tag and
220    /// style.
221    ///
222    /// The core schema tags `!!int`, `!!float`, `!!bool` and `!!null` set
223    /// the type when the content is a valid spelling of it; any other tag, or
224    /// content the tag cannot resolve, gives a string.
225    pub(crate) fn from_tagged(tagged: &crate::yaml::TaggedNode) -> Option<Self> {
226        let scalar = Self::from_scalar(&tagged.value()?);
227        Some(match tagged.tag() {
228            Some(tag) => scalar.with_resolved_tag(tag),
229            None => Self {
230                scalar_type: ScalarType::String,
231                ..scalar
232            },
233        })
234    }
235
236    /// Attach the tag written on this scalar's node, taking the type it
237    /// names when the content is a valid spelling of it and a string
238    /// otherwise.
239    pub(crate) fn with_resolved_tag(self, tag: String) -> Self {
240        let scalar_type = match core_tag_type(&tag) {
241            Some(t) if core_tag_accepts(t, &self.value) => t.into(),
242            _ => ScalarType::String,
243        };
244        Self {
245            scalar_type,
246            tag: Some(tag),
247            ..self
248        }
249    }
250
251    /// Set the type, for a tag that names one outside the core schema.
252    pub(crate) fn with_type(self, scalar_type: ScalarType) -> Self {
253        Self {
254            scalar_type,
255            ..self
256        }
257    }
258
259    /// Basic timestamp format validation
260    pub(super) fn is_valid_timestamp(value: &str) -> bool {
261        // The forms of the YAML timestamp type
262        // (https://yaml.org/type/timestamp.html): YYYY-MM-DD or
263        // YYYY-MM-DDTHH:MM:SS etc. A number of seconds is not one of them.
264        Self::matches_iso8601_pattern(value)
265    }
266
267    /// Whether `value` is a YAML 1.1 timestamp naming a valid date and
268    /// time: a date `YYYY-MM-DD`, or
269    /// `[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}([Tt]|[ \t]+)[0-9]{1,2}:[0-9]{2}:[0-9]{2}`
270    /// followed by a fraction and time zone.
271    fn matches_iso8601_pattern(value: &str) -> bool {
272        let chars: Vec<char> = value.chars().collect();
273        let rest = &mut &chars[..];
274        let Some(((year, _), (month, month_len), (day, day_len))) = (|| {
275            let year = take_number(rest, 4, 4)?;
276            take_char(rest, |c| c == '-')?;
277            let month = take_number(rest, 1, 2)?;
278            take_char(rest, |c| c == '-')?;
279            Some((year, month, take_number(rest, 1, 2)?))
280        })() else {
281            return false;
282        };
283        let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
284        let days_in_month = match month {
285            2 if leap => 29,
286            2 => 28,
287            4 | 6 | 9 | 11 => 30,
288            _ => 31,
289        };
290        if !(1..=12).contains(&month) || !(1..=days_in_month).contains(&day) {
291            return false;
292        }
293        // A date on its own has two-digit month and day.
294        if rest.is_empty() {
295            return month_len == 2 && day_len == 2;
296        }
297
298        let time = (|| {
299            if take_char(rest, |c| c == 'T' || c == 't').is_none() {
300                take_char(rest, |c| c == ' ' || c == '\t')?;
301                while take_char(rest, |c| c == ' ' || c == '\t').is_some() {}
302            }
303            let (hour, _) = take_number(rest, 1, 2)?;
304            take_char(rest, |c| c == ':')?;
305            let (minute, _) = take_number(rest, 2, 2)?;
306            take_char(rest, |c| c == ':')?;
307            Some((hour, minute, take_number(rest, 2, 2)?.0))
308        })();
309        match time {
310            Some((hour, minute, second)) if hour <= 23 && minute <= 59 && second <= 59 => {
311                Self::is_timestamp_tail(rest)
312            }
313            _ => false,
314        }
315    }
316
317    /// Whether `tail` is a valid fraction and time zone following the
318    /// seconds of a timestamp: `(\.[0-9]*)?([ \t]*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?))?`.
319    fn is_timestamp_tail(tail: &[char]) -> bool {
320        let digits = |s: &[char]| s.iter().all(|c| c.is_ascii_digit());
321        let mut rest = tail;
322        if let Some(('.', fraction)) = rest.split_first() {
323            rest = &fraction[fraction.iter().take_while(|c| c.is_ascii_digit()).count()..];
324        }
325        if rest.is_empty() {
326            return true;
327        }
328        let zone = &rest[rest.iter().take_while(|c| matches!(c, ' ' | '\t')).count()..];
329        match zone {
330            ['Z'] => true,
331            ['+' | '-', offset @ ..] => {
332                let (hour, minute) = match offset.iter().position(|&c| c == ':') {
333                    Some(i) => (&offset[..i], &offset[i + 1..]),
334                    None => (offset, &['0', '0'][..]),
335                };
336                matches!(hour.len(), 1 | 2) && digits(hour) && minute.len() == 2 && digits(minute)
337            }
338            _ => false,
339        }
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_timestamp_pattern_matching() {
349        // Valid patterns
350        assert!(ScalarValue::matches_iso8601_pattern("2023-12-25"));
351        assert!(ScalarValue::matches_iso8601_pattern("2023-12-25T10:30:45"));
352        assert!(ScalarValue::matches_iso8601_pattern("2023-12-25t10:30:45")); // Lowercase t
353        assert!(ScalarValue::matches_iso8601_pattern("2023-12-25 10:30:45"));
354        assert!(ScalarValue::matches_iso8601_pattern("2023-01-01T00:00:00"));
355        assert!(ScalarValue::matches_iso8601_pattern(
356            "2001-12-14t21:59:43.10-05:00"
357        )); // Complex with lowercase t
358
359        // Invalid patterns
360        assert!(!ScalarValue::matches_iso8601_pattern("2023-13-25")); // Invalid month
361        assert!(!ScalarValue::matches_iso8601_pattern("23-12-25")); // Wrong year format
362        assert!(!ScalarValue::matches_iso8601_pattern("2023/12/25")); // Wrong separator
363        assert!(!ScalarValue::matches_iso8601_pattern("not-a-date"));
364        assert!(!ScalarValue::matches_iso8601_pattern("2023"));
365    }
366
367    #[test]
368    fn test_unix_timestamp_bounds() {
369        // A number of seconds is not a YAML timestamp, whatever its size.
370        assert!(!ScalarValue::is_valid_timestamp("0"));
371        assert!(!ScalarValue::is_valid_timestamp("1"));
372        assert!(!ScalarValue::is_valid_timestamp("4102444799"));
373        assert!(!ScalarValue::is_valid_timestamp("4102444800"));
374        assert!(ScalarValue::is_valid_timestamp("2001-12-14"));
375
376        // Coercion to a timestamp uses the same check.
377        assert!(ScalarValue::string("1")
378            .coerce_to_type(ScalarType::Timestamp)
379            .is_none());
380        assert_eq!(
381            ScalarValue::from(1_700_000_000i64).coerce_to_type(ScalarType::Timestamp),
382            None
383        );
384        assert_eq!(
385            ScalarValue::string("2001-12-14").coerce_to_type(ScalarType::Timestamp),
386            Some(ScalarValue::timestamp("2001-12-14"))
387        );
388    }
389
390    #[test]
391    fn test_iso8601_time_component_bounds() {
392        // Hour/minute/second upper bounds: 23:59:59 is valid, anything past is not.
393        assert!(ScalarValue::matches_iso8601_pattern("2023-01-01T23:59:59"));
394        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-01T24:00:00"));
395        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-01T10:60:00"));
396        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-01T10:30:60"));
397
398        // Day bounds: 31 is valid, 0 and 32 are not.
399        assert!(ScalarValue::matches_iso8601_pattern("2023-01-31"));
400        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-00"));
401        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-32"));
402
403        // A malformed time separator falls through to invalid.
404        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-01X10:30:45"));
405        // Time present but seconds field non-numeric.
406        assert!(!ScalarValue::matches_iso8601_pattern("2023-01-01T10:30:xx"));
407
408        // The two date separators must each be '-'. A non-dash in either
409        // position must reject even though the digit groups parse as valid
410        // month/day. This pins the separator conjuncts in the format check.
411        assert!(!ScalarValue::matches_iso8601_pattern("2023X12-25"));
412        assert!(!ScalarValue::matches_iso8601_pattern("2023-12X25"));
413    }
414
415    #[test]
416    fn test_timestamp_rejects_impossible_dates_and_trailing_text() {
417        for text in [
418            "2023-02-30",
419            "2023-02-29",
420            "2023-04-31",
421            "2100-02-29",
422            "2023-01-01T10:00:00garbage",
423            "2023-01-01T10:00:00Zx",
424            "2023-01-01T10:00:00+",
425            "2023-01-01T10:00:00+01:0",
426        ] {
427            assert!(!ScalarValue::matches_iso8601_pattern(text), "{text}");
428            assert_eq!(
429                ScalarValue::auto_detect_type(text),
430                ScalarType::String,
431                "{text}"
432            );
433        }
434        for text in [
435            "2024-02-29",
436            "2000-02-29",
437            "2023-01-01T10:00:00Z",
438            "2023-01-01T10:00:00.123456Z",
439            "2023-01-01T10:00:00.5+01:00",
440            "2001-12-14 21:59:43.10 -5",
441            "2001-12-14t21:59:43.10-05:00",
442        ] {
443            assert!(ScalarValue::matches_iso8601_pattern(text), "{text}");
444        }
445    }
446}