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
162fn has_yaml_non_string_semantics(string: &str) -> bool {
167 [
168 "true", "false", "True", "False", "TRUE", "FALSE",
170 "null", "Null", "NULL", "~",
172 "y", "Y", "n", "N", "yes", "Yes", "YES", "no", "No", "NO", "on", "On", "ON", "off", "Off",
174 "OFF", ".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
188fn 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 '-' | '?' | ':' => chars.next().is_none_or(char::is_whitespace),
201 '[' | ']' | '{' | '}' | ',' | '#' | '&' | '*' | '!' | '|' | '>' | '\'' | '"' | '%'
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 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 let line_indent = indent + if is_first { 0 } else { first_prefix_len };
407 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 .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 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}