Skip to main content

nu_command/formats/to/
yaml.rs

1use nu_engine::command_prelude::*;
2use nu_protocol::ast::PathMember;
3use std::fmt::Write as _;
4
5/// YAML 1.1 boolean-like strings that need quoting when used as record keys.
6const YAML_11_BOOLEANS: &[&str] = &[
7    "y", "Y", "yes", "Yes", "YES", "n", "N", "no", "No", "NO", "on", "On", "ON", "off", "Off",
8    "OFF",
9];
10
11/// YAML special float and numeric strings that need quoting to preserve them as strings.
12const YAML_SPECIAL_NUMERICS: &[&str] = &[
13    ".inf", ".Inf", ".INF", "-.inf", "-.Inf", "-.INF", ".nan", ".NaN", ".NAN",
14];
15
16#[derive(Clone)]
17pub struct ToYamlLike(&'static str);
18pub const TO_YAML: ToYamlLike = ToYamlLike("to yaml");
19pub const TO_YML: ToYamlLike = ToYamlLike("to yml");
20
21impl Command for ToYamlLike {
22    fn name(&self) -> &str {
23        self.0
24    }
25
26    fn signature(&self) -> Signature {
27        Signature::build(self.name())
28            .input_output_types(vec![(Type::Any, Type::String)])
29            .switch(
30                "serialize",
31                "Serialize nushell types that cannot be deserialized.",
32                Some('s'),
33            )
34            .category(Category::Formats)
35    }
36
37    fn description(&self) -> &str {
38        "Convert table into .yaml/.yml text."
39    }
40
41    fn examples(&self) -> Vec<Example<'_>> {
42        vec![Example {
43            description: "Outputs a YAML string representing the contents of this table.",
44            example: match self.name() {
45                "to yaml" => r#"[[foo bar]; ["1" "2"]] | to yaml"#,
46                "to yml" => r#"[[foo bar]; ["1" "2"]] | to yml"#,
47                _ => unreachable!("only implemented for `yaml` and `yml`"),
48            },
49            result: Some(Value::test_string("- foo: '1'\n  bar: '2'\n")),
50        }]
51    }
52
53    fn run(
54        &self,
55        engine_state: &EngineState,
56        stack: &mut Stack,
57        call: &Call,
58        input: PipelineData,
59    ) -> Result<PipelineData, ShellError> {
60        let head = call.head;
61        let serialize_types = call.has_flag(engine_state, stack, "serialize")?;
62        let input = input.try_expand_range()?;
63
64        to_yaml(engine_state, input, head, serialize_types)
65    }
66}
67
68pub fn value_to_yaml_value(
69    engine_state: &EngineState,
70    v: &Value,
71    serialize_types: bool,
72) -> Result<serde_yaml::Value, ShellError> {
73    Ok(match &v {
74        Value::Bool { val, .. } => serde_yaml::Value::Bool(*val),
75        Value::Int { val, .. } => serde_yaml::Value::Number(serde_yaml::Number::from(*val)),
76        Value::Filesize { val, .. } => {
77            serde_yaml::Value::Number(serde_yaml::Number::from(val.get()))
78        }
79        Value::Duration { val, .. } => serde_yaml::Value::String(val.to_string()),
80        Value::Date { val, .. } => serde_yaml::Value::String(val.to_string()),
81        Value::Range { .. } => serde_yaml::Value::Null,
82        Value::Float { val, .. } => serde_yaml::Value::Number(serde_yaml::Number::from(*val)),
83        Value::String { val, .. } | Value::Glob { val, .. } => {
84            serde_yaml::Value::String(val.clone())
85        }
86        Value::Record { val, .. } => {
87            let mut m = serde_yaml::Mapping::new();
88            for (k, v) in &**val {
89                m.insert(
90                    serde_yaml::Value::String(k.clone()),
91                    value_to_yaml_value(engine_state, v, serialize_types)?,
92                );
93            }
94            serde_yaml::Value::Mapping(m)
95        }
96        Value::List { vals, .. } => {
97            let mut out = vec![];
98
99            for value in vals {
100                out.push(value_to_yaml_value(engine_state, value, serialize_types)?);
101            }
102
103            serde_yaml::Value::Sequence(out)
104        }
105        Value::Closure { val, .. } => {
106            if serialize_types {
107                let block = engine_state.get_block(val.block_id);
108                if let Some(span) = block.span {
109                    let contents_bytes = engine_state.get_span_contents(span);
110                    let contents_string = String::from_utf8_lossy(contents_bytes);
111                    serde_yaml::Value::String(contents_string.to_string())
112                } else {
113                    serde_yaml::Value::String(format!(
114                        "unable to retrieve block contents for yaml block_id {}",
115                        val.block_id.get()
116                    ))
117                }
118            } else {
119                serde_yaml::Value::Null
120            }
121        }
122        Value::Nothing { .. } => serde_yaml::Value::Null,
123        Value::Error { error, .. } => return Err(*error.clone()),
124        Value::Binary { val, .. } => serde_yaml::Value::Sequence(
125            val.iter()
126                .map(|x| serde_yaml::Value::Number(serde_yaml::Number::from(*x)))
127                .collect(),
128        ),
129        Value::CellPath { val, .. } => serde_yaml::Value::Sequence(
130            val.members
131                .iter()
132                .map(|x| match &x {
133                    PathMember::String { val, .. } => Ok(serde_yaml::Value::String(val.clone())),
134                    PathMember::Int { val, .. } => {
135                        Ok(serde_yaml::Value::Number(serde_yaml::Number::from(*val)))
136                    }
137                })
138                .collect::<Result<Vec<serde_yaml::Value>, ShellError>>()?,
139        ),
140        Value::Custom { .. } => serde_yaml::Value::Null,
141    })
142}
143
144fn render_yaml_string(value: &str) -> String {
145    if value.chars().any(char::is_control) {
146        let mut escaped = String::with_capacity(value.len() + 2);
147        escaped.push('"');
148
149        for ch in value.chars() {
150            match ch {
151                '"' => escaped.push_str("\\\""),
152                '\\' => escaped.push_str("\\\\"),
153                '\u{08}' => escaped.push_str("\\b"),
154                '\u{0C}' => escaped.push_str("\\f"),
155                '\n' => escaped.push_str("\\n"),
156                '\r' => escaped.push_str("\\r"),
157                '\t' => escaped.push_str("\\t"),
158                c if c.is_control() => {
159                    let _ = write!(escaped, "\\u{:04X}", c as u32);
160                }
161                c => escaped.push(c),
162            }
163        }
164
165        escaped.push('"');
166        escaped
167    } else {
168        format!("'{}'", value.replace('\'', "''"))
169    }
170}
171
172fn should_quote_yaml_key(key: &str) -> bool {
173    if key.is_empty() {
174        return true;
175    }
176    if key.chars().any(char::is_control) {
177        return true;
178    }
179    if key.starts_with(char::is_whitespace) || key.ends_with(char::is_whitespace) {
180        return true;
181    }
182    if YAML_11_BOOLEANS.contains(&key) {
183        return true;
184    }
185    if matches!(
186        key,
187        "~" | "null" | "Null" | "NULL" | "true" | "True" | "TRUE" | "false" | "False" | "FALSE"
188    ) {
189        return true;
190    }
191    // Check for YAML special numeric values (.inf, .nan) and hex/octal notation
192    if YAML_SPECIAL_NUMERICS.contains(&key) {
193        return true;
194    }
195    if key.starts_with("0x") || key.starts_with("0X") {
196        return true;
197    }
198    if key.starts_with("0o") || key.starts_with("0O") {
199        return true;
200    }
201    if key.parse::<i64>().is_ok() {
202        return true;
203    }
204    if key.parse::<u64>().is_ok() {
205        return true;
206    }
207    if key.parse::<f64>().is_ok() {
208        return true;
209    }
210    if !key
211        .chars()
212        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
213    {
214        return true;
215    }
216    false
217}
218
219fn render_yaml_key(key: &serde_yaml::Value) -> String {
220    match key {
221        serde_yaml::Value::String(key) if should_quote_yaml_key(key) => render_yaml_string(key),
222        serde_yaml::Value::String(key) => key.clone(),
223        _ => render_inline_yaml_value(key),
224    }
225}
226
227fn render_inline_yaml_value(value: &serde_yaml::Value) -> String {
228    match value {
229        serde_yaml::Value::Null => "null".to_string(),
230        serde_yaml::Value::Bool(value) => value.to_string(),
231        serde_yaml::Value::Number(value) => value.to_string(),
232        serde_yaml::Value::String(value) => render_yaml_string(value),
233        serde_yaml::Value::Sequence(values) => {
234            let values = values
235                .iter()
236                .map(render_inline_yaml_value)
237                .collect::<Vec<_>>()
238                .join(", ");
239            format!("[{values}]")
240        }
241        serde_yaml::Value::Mapping(entries) => {
242            let entries = entries
243                .iter()
244                .map(|(key, value)| {
245                    format!(
246                        "{}: {}",
247                        render_yaml_key(key),
248                        render_inline_yaml_value(value)
249                    )
250                })
251                .collect::<Vec<_>>()
252                .join(", ");
253            format!("{{{entries}}}")
254        }
255        serde_yaml::Value::Tagged(tagged) => {
256            format!("{} {}", tagged.tag, render_inline_yaml_value(&tagged.value))
257        }
258    }
259}
260
261fn is_inline_yaml_value(value: &serde_yaml::Value) -> bool {
262    match value {
263        serde_yaml::Value::Sequence(values) => values.is_empty(),
264        serde_yaml::Value::Mapping(entries) => entries.is_empty(),
265        serde_yaml::Value::Tagged(tagged) => is_inline_yaml_value(&tagged.value),
266        _ => true,
267    }
268}
269
270fn write_yaml_indent(output: &mut String, indent: usize) {
271    for _ in 0..indent {
272        output.push(' ');
273    }
274}
275
276fn write_yaml_value(output: &mut String, value: &serde_yaml::Value, indent: usize) {
277    match value {
278        serde_yaml::Value::Sequence(values) if !values.is_empty() => {
279            write_yaml_sequence(output, values, indent);
280        }
281        serde_yaml::Value::Mapping(entries) if !entries.is_empty() => {
282            write_yaml_mapping(output, entries, indent, "");
283        }
284        serde_yaml::Value::Tagged(tagged) => write_yaml_value(output, &tagged.value, indent),
285        _ => {
286            write_yaml_indent(output, indent);
287            output.push_str(&render_inline_yaml_value(value));
288            output.push('\n');
289        }
290    }
291}
292
293fn write_yaml_sequence(output: &mut String, values: &[serde_yaml::Value], indent: usize) {
294    for value in values {
295        match value {
296            serde_yaml::Value::Mapping(entries) if !entries.is_empty() => {
297                write_yaml_mapping(output, entries, indent, "- ");
298            }
299            value if is_inline_yaml_value(value) => {
300                write_yaml_indent(output, indent);
301                output.push_str("- ");
302                output.push_str(&render_inline_yaml_value(value));
303                output.push('\n');
304            }
305            _ => {
306                write_yaml_indent(output, indent);
307                output.push_str("-\n");
308                write_yaml_value(output, value, indent + 2);
309            }
310        }
311    }
312}
313
314fn write_yaml_mapping(
315    output: &mut String,
316    entries: &serde_yaml::Mapping,
317    indent: usize,
318    first_prefix: &str,
319) {
320    let first_prefix_len = first_prefix.len();
321
322    for (index, (key, value)) in entries.iter().enumerate() {
323        let is_first = index == 0;
324        // For the first entry, the prefix is written at the current indent level.
325        // For subsequent entries, we need to account for the prefix's length
326        // to maintain proper alignment with the first entry.
327        let line_indent = indent + if is_first { 0 } else { first_prefix_len };
328        // The key starts after the prefix (already written for first entry,
329        // will be written as part of indent for subsequent entries).
330        let key_indent = line_indent + if is_first { first_prefix_len } else { 0 };
331
332        write_yaml_indent(output, line_indent);
333        if is_first {
334            output.push_str(first_prefix);
335        }
336
337        output.push_str(&render_yaml_key(key));
338
339        if is_inline_yaml_value(value) {
340            output.push_str(": ");
341            output.push_str(&render_inline_yaml_value(value));
342            output.push('\n');
343        } else {
344            output.push_str(":\n");
345            write_yaml_value(output, value, key_indent + 2);
346        }
347    }
348}
349
350fn yaml_value_to_string(value: &serde_yaml::Value) -> String {
351    let mut output = String::new();
352    write_yaml_value(&mut output, value, 0);
353    output
354}
355
356fn to_yaml(
357    engine_state: &EngineState,
358    mut input: PipelineData,
359    head: Span,
360    serialize_types: bool,
361) -> Result<PipelineData, ShellError> {
362    let metadata = input
363        .take_metadata()
364        .unwrap_or_default()
365        // Per RFC-9512, application/yaml should be used
366        .with_content_type(Some("application/yaml".into()));
367    let value = input.into_value(head)?;
368
369    let yaml_value = value_to_yaml_value(engine_state, &value, serialize_types)?;
370    let yaml_string = yaml_value_to_string(&yaml_value);
371    Ok(Value::string(yaml_string, head).into_pipeline_data_with_metadata(Some(metadata)))
372}
373
374#[cfg(test)]
375mod test {
376    use super::*;
377    use crate::{Get, Metadata};
378    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
379
380    #[test]
381    fn test_examples() -> nu_test_support::Result {
382        nu_test_support::test().examples(TO_YAML)?;
383        nu_test_support::test().examples(TO_YML)
384    }
385
386    #[test]
387    fn test_content_type_metadata() {
388        let mut engine_state = Box::new(EngineState::new());
389        let delta = {
390            // Base functions that are needed for testing
391            // Try to keep this working set small to keep tests running as fast as possible
392            let mut working_set = StateWorkingSet::new(&engine_state);
393
394            working_set.add_decl(Box::new(TO_YAML));
395            working_set.add_decl(Box::new(Metadata {}));
396            working_set.add_decl(Box::new(Get {}));
397
398            working_set.render()
399        };
400
401        engine_state
402            .merge_delta(delta)
403            .expect("Error merging delta");
404
405        let cmd = "{a: 1 b: 2} | to yaml  | metadata | get content_type | $in";
406        let result = eval_pipeline_without_terminal_expression(
407            cmd,
408            std::env::temp_dir().as_ref(),
409            &mut engine_state,
410        );
411        assert_eq!(
412            Value::test_string("application/yaml"),
413            result.expect("There should be a result")
414        );
415    }
416}