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