Skip to main content

brain_brew_formats/
yaml_scalar.rs

1use std::fmt::Write as _;
2
3/// Emit one YAML scalar with the same rules for every hand-rolled emitter.
4///
5/// Plain scalars are intentionally conservative: `:` is allowed only where YAML
6/// cannot treat it as a mapping delimiter (`http://example` is plain, `key: value`
7/// and `label:` are quoted), and a serde_yaml probe must parse the candidate back
8/// as the same string. Everything else is quoted losslessly.
9pub 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
19/// Emit one YAML mapping key.
20///
21/// Quoted scalars can represent most hostile key text, but physical line-break
22/// characters make single-line hand-rolled mappings too easy to corrupt. Callers
23/// with fallible parse/validation paths should reject those keys before emit.
24pub fn key(value: &str) -> Option<String> {
25    is_emittable_key(value).then(|| scalar(value))
26}
27
28/// Return whether a key can be emitted by the hand-rolled single-line mapping writers.
29pub fn is_emittable_key(value: &str) -> bool {
30    !value.contains(['\n', '\r'])
31}
32
33/// Write `key: value` using a block scalar for multiline values and `scalar` otherwise.
34///
35/// An explicit `2` indentation indicator is added when YAML auto-detection would
36/// corrupt a value whose first content line starts with whitespace. Chomp
37/// indicators preserve the exact trailing-newline shape. Block scalars are used
38/// only for text that YAML will not reinterpret as line separators or controls;
39/// unsafe multiline text falls back to double-quoted escapes.
40pub 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}