Skip to main content

nu_command/formats/to/
yaml.rs

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