brain_brew_formats/
yaml_scalar.rs1use std::fmt::Write as _;
2
3pub fn scalar(value: &str) -> String {
10 if can_emit_plain_scalar(value) {
11 value.to_owned()
12 } else if needs_double_quoted_scalar(value) {
13 double_quoted_scalar(value)
14 } else {
15 single_quoted_scalar(value)
16 }
17}
18
19pub fn key(value: &str) -> Option<String> {
25 is_emittable_key(value).then(|| scalar(value))
26}
27
28pub fn is_emittable_key(value: &str) -> bool {
30 !value.contains(['\n', '\r'])
31}
32
33pub fn write_multiline_or_scalar(out: &mut String, indent: &str, key: &str, value: &str) {
41 let key = self::key(key).expect("emitted YAML key was not prevalidated");
42 if can_emit_block_scalar(value) {
43 let chomp = match trailing_newline_count(value) {
44 0 => "-",
45 1 => "",
46 _ => "+",
47 };
48 let indentation = if needs_explicit_block_indent(value) {
49 "2"
50 } else {
51 ""
52 };
53 writeln!(out, "{indent}{key}: |{indentation}{chomp}")
54 .expect("writing to a string cannot fail");
55 for line in block_content_lines(value) {
56 writeln!(out, "{indent} {line}").expect("writing to a string cannot fail");
57 }
58 } else {
59 writeln!(out, "{indent}{key}: {}", scalar(value)).expect("writing to a string cannot fail");
60 }
61}
62
63fn can_emit_plain_scalar(value: &str) -> bool {
64 !value.is_empty()
65 && !value.starts_with([
66 ' ', '-', '?', ':', '@', '`', '&', '*', '!', '|', '>', '#', '{', '[', ',', '\t',
67 ])
68 && !value.ends_with([' ', '\t', ':'])
69 && !contains_colon_mapping_indicator(value)
70 && value.chars().all(is_allowed_plain_char)
71 && parses_as_same_string(value)
72}
73
74fn is_allowed_plain_char(ch: char) -> bool {
75 ch.is_ascii_alphanumeric() || matches!(ch, ' ' | '.' | ',' | '_' | '-' | '/' | ':')
76}
77
78fn contains_colon_mapping_indicator(value: &str) -> bool {
79 value
80 .as_bytes()
81 .windows(2)
82 .any(|window| window[0] == b':' && matches!(window[1], b' ' | b'\t'))
83}
84
85fn parses_as_same_string(value: &str) -> bool {
86 let input = format!("value: {value}\n");
87 let Ok(serde_yaml::Value::Mapping(mapping)) = serde_yaml::from_str::<serde_yaml::Value>(&input)
88 else {
89 return false;
90 };
91 mapping
92 .get(serde_yaml::Value::String("value".to_owned()))
93 .is_some_and(|parsed| parsed == &serde_yaml::Value::String(value.to_owned()))
94}
95
96fn needs_double_quoted_scalar(value: &str) -> bool {
97 value.chars().any(|ch| {
98 matches!(
99 ch,
100 '\0'..='\u{1f}' | '\u{7f}' | '\u{85}' | '\u{2028}' | '\u{2029}'
101 )
102 })
103}
104
105fn single_quoted_scalar(value: &str) -> String {
106 format!("'{}'", value.replace('\'', "''"))
107}
108
109fn double_quoted_scalar(value: &str) -> String {
110 let mut out = String::with_capacity(value.len() + 2);
111 out.push('"');
112 for ch in value.chars() {
113 match ch {
114 '\0' => out.push_str("\\0"),
115 '\u{07}' => out.push_str("\\a"),
116 '\u{08}' => out.push_str("\\b"),
117 '\t' => out.push_str("\\t"),
118 '\n' => out.push_str("\\n"),
119 '\u{0b}' => out.push_str("\\v"),
120 '\u{0c}' => out.push_str("\\f"),
121 '\r' => out.push_str("\\r"),
122 '\u{1b}' => out.push_str("\\e"),
123 '\u{85}' => out.push_str("\\N"),
124 '\u{2028}' => out.push_str("\\L"),
125 '\u{2029}' => out.push_str("\\P"),
126 '"' => out.push_str("\\\""),
127 '\\' => out.push_str("\\\\"),
128 '\u{01}'..='\u{06}' | '\u{0e}'..='\u{1a}' | '\u{1c}'..='\u{1f}' | '\u{7f}' => {
129 write!(out, "\\x{:02X}", ch as u32).expect("writing to a string cannot fail");
130 }
131 _ => out.push(ch),
132 }
133 }
134 out.push('"');
135 out
136}
137
138fn can_emit_block_scalar(value: &str) -> bool {
139 value.contains('\n') && !value.starts_with('\n') && value.chars().all(is_block_safe_char)
140}
141
142fn is_block_safe_char(ch: char) -> bool {
143 ch == '\n'
144 || !matches!(
145 ch,
146 '\0'..='\u{1f}' | '\u{7f}' | '\u{85}' | '\u{2028}' | '\u{2029}'
147 )
148}
149
150fn needs_explicit_block_indent(value: &str) -> bool {
151 value.starts_with([' ', '\t'])
152}
153
154fn trailing_newline_count(value: &str) -> usize {
155 value
156 .bytes()
157 .rev()
158 .take_while(|byte| *byte == b'\n')
159 .count()
160}
161
162fn block_content_lines(value: &str) -> Vec<&str> {
163 let mut lines = value.split('\n').collect::<Vec<_>>();
164 if value.ends_with('\n') {
165 lines.pop();
166 }
167 lines
168}