Skip to main content

ephemeris_documents/yaml/
scalar_text.rs

1//! Text of a YAML scalar, written plain or quoted.
2
3use std::fmt;
4
5use crate::text::is_unsafe_text_char;
6
7const INDICATORS: &str = "-?:,[]{}#&*!|>'\"%@`";
8const RESERVED: [&str; 10] = [
9    "null", "Null", "NULL", "~", "true", "True", "TRUE", "false", "False", "FALSE",
10];
11
12const YAML_1_1_BOOLEANS: [&str; 16] = [
13    "y", "Y", "yes", "Yes", "YES", "n", "N", "no", "No", "NO", "on", "On", "ON", "off", "Off",
14    "OFF",
15];
16
17/// Where a scalar is written. Keys keep the YAML 1.1 booleans plain, so that
18/// a key such as `on` stays as it is.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Position {
21    Key,
22    Value,
23}
24
25/// Text written plain when that is safe, and quoted otherwise.
26///
27/// The check is conservative: over quoting never changes a value, under
28/// quoting can. A value that YAML 1.1 reads as a boolean is quoted.
29pub(super) struct ScalarText<'a> {
30    text: &'a str,
31    position: Position,
32}
33
34impl<'a> ScalarText<'a> {
35    pub(super) const fn key(text: &'a str) -> Self {
36        Self {
37            text,
38            position: Position::Key,
39        }
40    }
41
42    pub(super) const fn value(text: &'a str) -> Self {
43        Self {
44            text,
45            position: Position::Value,
46        }
47    }
48}
49
50impl ScalarText<'_> {
51    pub(super) fn is_safe(&self) -> bool {
52        let text = self.text;
53        let (Some(first), Some(last)) = (text.chars().next(), text.chars().next_back()) else {
54            return false;
55        };
56        !(first.is_whitespace()
57            || last.is_whitespace()
58            || last == ':'
59            || INDICATORS.contains(first)
60            || text.starts_with("...")
61            || text == "<<"
62            || text.contains(": ")
63            || text.contains(" #")
64            || text
65                .chars()
66                .any(|c| c.is_control() || is_unsafe_text_char(c))
67            || RESERVED.contains(&text)
68            || (self.position == Position::Value && YAML_1_1_BOOLEANS.contains(&text))
69            || Self::looks_numeric(text))
70    }
71
72    /// Whether the YAML 1.2 core schema, or a leading zero form, reads `text`
73    /// as a number.
74    fn looks_numeric(text: &str) -> bool {
75        let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text);
76        let lower = unsigned.to_ascii_lowercase();
77        if lower == ".inf" || lower == ".nan" {
78            return true;
79        }
80        if let Some(rest) = lower.strip_prefix("0x") {
81            return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_hexdigit());
82        }
83        if let Some(rest) = lower.strip_prefix("0o") {
84            return !rest.is_empty() && rest.chars().all(|c| c.is_digit(8));
85        }
86        Self::is_decimal(&lower)
87    }
88
89    fn is_decimal(text: &str) -> bool {
90        let (mantissa, exponent) = match text.split_once('e') {
91            Some((mantissa, exponent)) => (mantissa, Some(exponent)),
92            None => (text, None),
93        };
94        let (whole, fraction) = match mantissa.split_once('.') {
95            Some((whole, fraction)) => (whole, Some(fraction)),
96            None => (mantissa, None),
97        };
98        let all_digits = |part: &str| part.chars().all(|c| c.is_ascii_digit());
99        let mantissa_ok = fraction.map_or_else(
100            || !whole.is_empty() && all_digits(whole),
101            |fraction| {
102                all_digits(whole)
103                    && all_digits(fraction)
104                    && !(whole.is_empty() && fraction.is_empty())
105            },
106        );
107        let exponent_ok = exponent.is_none_or(|exponent| {
108            let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
109            !digits.is_empty() && all_digits(digits)
110        });
111        mantissa_ok && exponent_ok
112    }
113}
114
115impl ScalarText<'_> {
116    /// Double quotes are needed only for text that must be escaped; single
117    /// quotes carry everything else verbatim, with `'` doubled.
118    fn needs_double_quotes(&self) -> bool {
119        self.text
120            .chars()
121            .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
122    }
123}
124
125impl fmt::Display for ScalarText<'_> {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        if self.is_safe() {
128            return f.write_str(self.text);
129        }
130        if !self.needs_double_quotes() {
131            return write!(f, "'{}'", self.text.replace('\'', "''"));
132        }
133        f.write_str("\"")?;
134        for c in self.text.chars() {
135            match c {
136                '\\' => f.write_str("\\\\")?,
137                '"' => f.write_str("\\\"")?,
138                '\n' => f.write_str("\\n")?,
139                '\r' => f.write_str("\\r")?,
140                '\t' => f.write_str("\\t")?,
141                '\u{2028}' => f.write_str("\\u2028")?,
142                '\u{2029}' => f.write_str("\\u2029")?,
143                c if c.is_control() => write!(f, "\\x{:02x}", u32::from(c))?,
144                c => write!(f, "{c}")?,
145            }
146        }
147        f.write_str("\"")
148    }
149}
150
151#[cfg(test)]
152mod tests;