ephemeris_documents/yaml/
scalar_text.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Position {
21 Key,
22 Value,
23}
24
25pub(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 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 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;