nu_command/formats/from/
yaml.rs

1use indexmap::IndexMap;
2use itertools::Itertools;
3use nu_engine::command_prelude::*;
4use serde::de::Deserialize;
5
6#[derive(Clone)]
7pub struct FromYaml;
8
9impl Command for FromYaml {
10    fn name(&self) -> &str {
11        "from yaml"
12    }
13
14    fn signature(&self) -> Signature {
15        Signature::build("from yaml")
16            .input_output_types(vec![(Type::String, Type::Any)])
17            .category(Category::Formats)
18    }
19
20    fn description(&self) -> &str {
21        "Parse text as .yaml/.yml and create table."
22    }
23
24    fn examples(&self) -> Vec<Example> {
25        get_examples()
26    }
27
28    fn run(
29        &self,
30        _engine_state: &EngineState,
31        _stack: &mut Stack,
32        call: &Call,
33        input: PipelineData,
34    ) -> Result<PipelineData, ShellError> {
35        let head = call.head;
36        from_yaml(input, head)
37    }
38}
39
40#[derive(Clone)]
41pub struct FromYml;
42
43impl Command for FromYml {
44    fn name(&self) -> &str {
45        "from yml"
46    }
47
48    fn signature(&self) -> Signature {
49        Signature::build("from yml")
50            .input_output_types(vec![(Type::String, Type::Any)])
51            .category(Category::Formats)
52    }
53
54    fn description(&self) -> &str {
55        "Parse text as .yaml/.yml and create table."
56    }
57
58    fn run(
59        &self,
60        _engine_state: &EngineState,
61        _stack: &mut Stack,
62        call: &Call,
63        input: PipelineData,
64    ) -> Result<PipelineData, ShellError> {
65        let head = call.head;
66        from_yaml(input, head)
67    }
68
69    fn examples(&self) -> Vec<Example> {
70        get_examples()
71    }
72}
73
74fn convert_yaml_value_to_nu_value(
75    v: &serde_yaml::Value,
76    span: Span,
77    val_span: Span,
78) -> Result<Value, ShellError> {
79    let err_not_compatible_number = ShellError::UnsupportedInput {
80        msg: "Expected a nu-compatible number in YAML input".to_string(),
81        input: "value originates from here".into(),
82        msg_span: span,
83        input_span: val_span,
84    };
85    Ok(match v {
86        serde_yaml::Value::Bool(b) => Value::bool(*b, span),
87        serde_yaml::Value::Number(n) if n.is_i64() => {
88            Value::int(n.as_i64().ok_or(err_not_compatible_number)?, span)
89        }
90        serde_yaml::Value::Number(n) if n.is_f64() => {
91            Value::float(n.as_f64().ok_or(err_not_compatible_number)?, span)
92        }
93        serde_yaml::Value::String(s) => Value::string(s.to_string(), span),
94        serde_yaml::Value::Sequence(a) => {
95            let result: Result<Vec<Value>, ShellError> = a
96                .iter()
97                .map(|x| convert_yaml_value_to_nu_value(x, span, val_span))
98                .collect();
99            Value::list(result?, span)
100        }
101        serde_yaml::Value::Mapping(t) => {
102            // Using an IndexMap ensures consistent ordering
103            let mut collected = IndexMap::new();
104
105            for (k, v) in t {
106                // A ShellError that we re-use multiple times in the Mapping scenario
107                let err_unexpected_map = ShellError::UnsupportedInput {
108                    msg: format!("Unexpected YAML:\nKey: {k:?}\nValue: {v:?}"),
109                    input: "value originates from here".into(),
110                    msg_span: span,
111                    input_span: val_span,
112                };
113                match (k, v) {
114                    (serde_yaml::Value::Number(k), _) => {
115                        collected.insert(
116                            k.to_string(),
117                            convert_yaml_value_to_nu_value(v, span, val_span)?,
118                        );
119                    }
120                    (serde_yaml::Value::Bool(k), _) => {
121                        collected.insert(
122                            k.to_string(),
123                            convert_yaml_value_to_nu_value(v, span, val_span)?,
124                        );
125                    }
126                    (serde_yaml::Value::String(k), _) => {
127                        collected.insert(
128                            k.clone(),
129                            convert_yaml_value_to_nu_value(v, span, val_span)?,
130                        );
131                    }
132                    // Hard-code fix for cases where "v" is a string without quotations with double curly braces
133                    // e.g. k = value
134                    // value: {{ something }}
135                    // Strangely, serde_yaml returns
136                    // "value" -> Mapping(Mapping { map: {Mapping(Mapping { map: {String("something"): Null} }): Null} })
137                    (serde_yaml::Value::Mapping(m), serde_yaml::Value::Null) => {
138                        return m
139                            .iter()
140                            .take(1)
141                            .collect_vec()
142                            .first()
143                            .and_then(|e| match e {
144                                (serde_yaml::Value::String(s), serde_yaml::Value::Null) => {
145                                    Some(Value::string("{{ ".to_owned() + s.as_str() + " }}", span))
146                                }
147                                _ => None,
148                            })
149                            .ok_or(err_unexpected_map);
150                    }
151                    (_, _) => {
152                        return Err(err_unexpected_map);
153                    }
154                }
155            }
156
157            Value::record(collected.into_iter().collect(), span)
158        }
159        serde_yaml::Value::Tagged(t) => {
160            let tag = &t.tag;
161
162            match &t.value {
163                serde_yaml::Value::String(s) => {
164                    let val = format!("{tag} {s}").trim().to_string();
165                    Value::string(val, span)
166                }
167                serde_yaml::Value::Number(n) => {
168                    let val = format!("{tag} {n}").trim().to_string();
169                    Value::string(val, span)
170                }
171                serde_yaml::Value::Bool(b) => {
172                    let val = format!("{tag} {b}").trim().to_string();
173                    Value::string(val, span)
174                }
175                serde_yaml::Value::Null => {
176                    let val = format!("{tag}").trim().to_string();
177                    Value::string(val, span)
178                }
179                v => convert_yaml_value_to_nu_value(v, span, val_span)?,
180            }
181        }
182        serde_yaml::Value::Null => Value::nothing(span),
183        x => unimplemented!("Unsupported YAML case: {:?}", x),
184    })
185}
186
187pub fn from_yaml_string_to_value(s: &str, span: Span, val_span: Span) -> Result<Value, ShellError> {
188    let mut documents = vec![];
189
190    for document in serde_yaml::Deserializer::from_str(s) {
191        let v: serde_yaml::Value =
192            serde_yaml::Value::deserialize(document).map_err(|x| ShellError::UnsupportedInput {
193                msg: format!("Could not load YAML: {x}"),
194                input: "value originates from here".into(),
195                msg_span: span,
196                input_span: val_span,
197            })?;
198        documents.push(convert_yaml_value_to_nu_value(&v, span, val_span)?);
199    }
200
201    match documents.len() {
202        0 => Ok(Value::nothing(span)),
203        1 => Ok(documents.remove(0)),
204        _ => Ok(Value::list(documents, span)),
205    }
206}
207
208pub fn get_examples() -> Vec<Example<'static>> {
209    vec![
210        Example {
211            example: "'a: 1' | from yaml",
212            description: "Converts yaml formatted string to table",
213            result: Some(Value::test_record(record! {
214                "a" => Value::test_int(1),
215            })),
216        },
217        Example {
218            example: "'[ a: 1, b: [1, 2] ]' | from yaml",
219            description: "Converts yaml formatted string to table",
220            result: Some(Value::test_list(vec![
221                Value::test_record(record! {
222                    "a" => Value::test_int(1),
223                }),
224                Value::test_record(record! {
225                    "b" => Value::test_list(
226                        vec![Value::test_int(1), Value::test_int(2)],),
227                }),
228            ])),
229        },
230    ]
231}
232
233fn from_yaml(input: PipelineData, head: Span) -> Result<PipelineData, ShellError> {
234    let (concat_string, span, metadata) = input.collect_string_strict(head)?;
235
236    match from_yaml_string_to_value(&concat_string, head, span) {
237        Ok(x) => {
238            Ok(x.into_pipeline_data_with_metadata(metadata.map(|md| md.with_content_type(None))))
239        }
240        Err(other) => Err(other),
241    }
242}
243
244#[cfg(test)]
245mod test {
246    use crate::Reject;
247    use crate::{Metadata, MetadataSet};
248
249    use super::*;
250    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
251    use nu_protocol::Config;
252
253    #[test]
254    fn test_problematic_yaml() {
255        struct TestCase {
256            description: &'static str,
257            input: &'static str,
258            expected: Result<Value, ShellError>,
259        }
260        let tt: Vec<TestCase> = vec![
261            TestCase {
262                description: "Double Curly Braces With Quotes",
263                input: r#"value: "{{ something }}""#,
264                expected: Ok(Value::test_record(record! {
265                    "value" => Value::test_string("{{ something }}"),
266                })),
267            },
268            TestCase {
269                description: "Double Curly Braces Without Quotes",
270                input: r#"value: {{ something }}"#,
271                expected: Ok(Value::test_record(record! {
272                    "value" => Value::test_string("{{ something }}"),
273                })),
274            },
275        ];
276        let config = Config::default();
277        for tc in tt {
278            let actual = from_yaml_string_to_value(tc.input, Span::test_data(), Span::test_data());
279            if actual.is_err() {
280                assert!(
281                    tc.expected.is_err(),
282                    "actual is Err for test:\nTest Description {}\nErr: {:?}",
283                    tc.description,
284                    actual
285                );
286            } else {
287                assert_eq!(
288                    actual.unwrap().to_expanded_string("", &config),
289                    tc.expected.unwrap().to_expanded_string("", &config)
290                );
291            }
292        }
293    }
294
295    #[test]
296    fn test_examples() {
297        use crate::test_examples;
298
299        test_examples(FromYaml {})
300    }
301
302    #[test]
303    fn test_consistent_mapping_ordering() {
304        let test_yaml = "- a: b
305  b: c
306- a: g
307  b: h";
308
309        // Before the fix this test is verifying, the ordering of columns in the resulting
310        // table was non-deterministic. It would take a few executions of the YAML conversion to
311        // see this ordering difference. This loop should be far more than enough to catch a regression.
312        for ii in 1..1000 {
313            let actual = from_yaml_string_to_value(test_yaml, Span::test_data(), Span::test_data());
314
315            let expected: Result<Value, ShellError> = Ok(Value::test_list(vec![
316                Value::test_record(record! {
317                    "a" => Value::test_string("b"),
318                    "b" => Value::test_string("c"),
319                }),
320                Value::test_record(record! {
321                    "a" => Value::test_string("g"),
322                    "b" => Value::test_string("h"),
323                }),
324            ]));
325
326            // Unfortunately the eq function for Value doesn't compare well enough to detect
327            // ordering errors in List columns or values.
328
329            assert!(actual.is_ok());
330            let actual = actual.ok().unwrap();
331            let expected = expected.ok().unwrap();
332
333            let actual_vals = actual.as_list().unwrap();
334            let expected_vals = expected.as_list().unwrap();
335            assert_eq!(expected_vals.len(), actual_vals.len(), "iteration {ii}");
336
337            for jj in 0..expected_vals.len() {
338                let actual_record = actual_vals[jj].as_record().unwrap();
339                let expected_record = expected_vals[jj].as_record().unwrap();
340
341                let actual_columns = actual_record.columns();
342                let expected_columns = expected_record.columns();
343                assert!(
344                    expected_columns.eq(actual_columns),
345                    "record {jj}, iteration {ii}"
346                );
347
348                let actual_vals = actual_record.values();
349                let expected_vals = expected_record.values();
350                assert!(expected_vals.eq(actual_vals), "record {jj}, iteration {ii}")
351            }
352        }
353    }
354
355    #[test]
356    fn test_convert_yaml_value_to_nu_value_for_tagged_values() {
357        struct TestCase {
358            input: &'static str,
359            expected: Result<Value, ShellError>,
360        }
361
362        let test_cases: Vec<TestCase> = vec![
363            TestCase {
364                input: "Key: !Value ${TEST}-Test-role",
365                expected: Ok(Value::test_record(record! {
366                    "Key" => Value::test_string("!Value ${TEST}-Test-role"),
367                })),
368            },
369            TestCase {
370                input: "Key: !Value test-${TEST}",
371                expected: Ok(Value::test_record(record! {
372                    "Key" => Value::test_string("!Value test-${TEST}"),
373                })),
374            },
375            TestCase {
376                input: "Key: !Value",
377                expected: Ok(Value::test_record(record! {
378                    "Key" => Value::test_string("!Value"),
379                })),
380            },
381            TestCase {
382                input: "Key: !True",
383                expected: Ok(Value::test_record(record! {
384                    "Key" => Value::test_string("!True"),
385                })),
386            },
387            TestCase {
388                input: "Key: !123",
389                expected: Ok(Value::test_record(record! {
390                    "Key" => Value::test_string("!123"),
391                })),
392            },
393        ];
394
395        for test_case in test_cases {
396            let doc = serde_yaml::Deserializer::from_str(test_case.input);
397            let v: serde_yaml::Value = serde_yaml::Value::deserialize(doc.last().unwrap()).unwrap();
398            let result = convert_yaml_value_to_nu_value(&v, Span::test_data(), Span::test_data());
399            assert!(result.is_ok());
400            assert!(result.ok().unwrap() == test_case.expected.ok().unwrap());
401        }
402    }
403
404    #[test]
405    fn test_content_type_metadata() {
406        let mut engine_state = Box::new(EngineState::new());
407        let delta = {
408            let mut working_set = StateWorkingSet::new(&engine_state);
409
410            working_set.add_decl(Box::new(FromYaml {}));
411            working_set.add_decl(Box::new(Metadata {}));
412            working_set.add_decl(Box::new(MetadataSet {}));
413            working_set.add_decl(Box::new(Reject {}));
414
415            working_set.render()
416        };
417
418        engine_state
419            .merge_delta(delta)
420            .expect("Error merging delta");
421
422        let cmd = r#""a: 1\nb: 2" | metadata set --content-type 'application/yaml' --datasource-ls | from yaml | metadata | reject span | $in"#;
423        let result = eval_pipeline_without_terminal_expression(
424            cmd,
425            std::env::temp_dir().as_ref(),
426            &mut engine_state,
427        );
428        assert_eq!(
429            Value::test_record(record!("source" => Value::test_string("ls"))),
430            result.expect("There should be a result")
431        )
432    }
433}