Skip to main content

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