Skip to main content

nu_command/formats/to/
kdl.rs

1use crate::formats::kdl::{
2    KdlFormat, KdlMetadata, KdlSpec, document_to_string, node_rows_to_kdl_document,
3    resolve_non_roundtrip, value_to_jik_document,
4};
5use nu_engine::command_prelude::*;
6
7#[derive(Clone)]
8pub struct ToKdl;
9
10impl Command for ToKdl {
11    fn name(&self) -> &str {
12        "to kdl"
13    }
14
15    fn signature(&self) -> Signature {
16        Signature::build("to kdl")
17            .input_output_types(vec![(Type::Any, Type::String)])
18            .param(
19                Flag::new("spec")
20                    .arg(SyntaxShape::Int)
21                    .desc("KDL language version (1 or 2 (default)).")
22                    .completion(Completion::new_list(&["1", "2"])),
23            )
24            .param(
25                Flag::new("format")
26                    .arg(SyntaxShape::String)
27                    .desc(
28                        "Data model: 'jik' (default) for JSON-in-KDL, or 'nodes' for KDL AST rows.",
29                    )
30                    .completion(Completion::new_list(&["nodes", "jik"])),
31            )
32            .switch(
33                "serialize",
34                "Serialize nushell types that cannot be deserialized (shorthand for --non-roundtrip lossy).",
35                Some('s'),
36            )
37            .param(
38                Flag::new("non-roundtrip")
39                    .arg(SyntaxShape::String)
40                    .desc("How to handle values that are non-roundtrippable ('error' (default), 'null', or 'lossy').")
41                    .completion(Completion::new_list(&["error", "null", "lossy"])),
42            )
43            .category(Category::Formats)
44    }
45
46    fn description(&self) -> &str {
47        "Converts structured data into KDL text."
48    }
49
50    fn search_terms(&self) -> Vec<&str> {
51        vec!["convert", "export", "config"]
52    }
53
54    fn run(
55        &self,
56        engine_state: &EngineState,
57        stack: &mut Stack,
58        call: &Call,
59        mut input: PipelineData,
60    ) -> Result<PipelineData, ShellError> {
61        let call_span = input.span().unwrap_or(call.head);
62        let mut metadata = input.take_metadata().unwrap_or_default();
63
64        let from_meta = KdlMetadata::read_from(&metadata);
65        KdlMetadata::clear(&mut metadata);
66        let metadata = metadata.with_content_type(Some("application/x-kdl".to_owned()));
67
68        let format = match call.get_flag::<String>(engine_state, stack, "format")? {
69            Some(s) => {
70                let flag_span = call.get_flag_span(stack, "format").unwrap_or(call.head);
71                KdlFormat::parse(&s, flag_span)?
72            }
73            None => from_meta
74                .as_ref()
75                .map(|m| m.format)
76                .unwrap_or(KdlFormat::Jik),
77        };
78
79        let spec = match call.get_flag::<i64>(engine_state, stack, "spec")? {
80            Some(n) => {
81                let flag_span = call.get_flag_span(stack, "spec").unwrap_or(call.head);
82                KdlSpec::from_i64(n, flag_span)?
83            }
84            None => from_meta.as_ref().map(|m| m.spec).unwrap_or_default(),
85        };
86
87        let non_roundtrip_flag =
88            call.get_flag::<Spanned<String>>(engine_state, stack, "non-roundtrip")?;
89        let serialize = call.has_flag(engine_state, stack, "serialize")?;
90
91        // Match YAML mutual exclusion for --serialize vs --non-roundtrip when both set with non-lossy.
92        if serialize
93            && let Some(nr) = non_roundtrip_flag.as_ref()
94            && nr.item != "lossy"
95        {
96            return Err(ShellError::IncompatibleParameters {
97                left_message: "this is a shorthand to".into(),
98                left_span: call.get_flag_span(stack, "serialize").unwrap_or(call.head),
99                right_message: "this with `lossy`".into(),
100                right_span: nr.span,
101            });
102        }
103
104        let non_roundtrip = resolve_non_roundtrip(serialize, non_roundtrip_flag, engine_state)?;
105
106        let value = input.into_value(call_span)?;
107
108        let document = match format {
109            KdlFormat::Jik => value_to_jik_document(&value, &non_roundtrip, call_span)?,
110            KdlFormat::Nodes => node_rows_to_kdl_document(&value, &non_roundtrip, call_span)?,
111        };
112
113        let output_string = document_to_string(document, spec);
114
115        Ok(output_string
116            .into_value(call_span)
117            .into_pipeline_data_with_metadata(metadata))
118    }
119
120    fn examples(&self) -> Vec<Example<'_>> {
121        vec![
122            Example {
123                description: "Convert a record to JSON-in-KDL (default KDL v2 keywords).",
124                example: "{a: 1, b: true} | to kdl",
125                result: Some(Value::test_string("- a=1 b=#true\n")),
126            },
127            Example {
128                description: "Emit KDL v2 explicitly with --spec 2.",
129                example: "{a: 1, b: true} | to kdl --spec 2",
130                result: Some(Value::test_string("- a=1 b=#true\n")),
131            },
132            Example {
133                description: "Emit KDL v1 keywords with --spec 1 (bare true/false/null).",
134                example: "{a: 1, b: true} | to kdl --spec 1",
135                result: Some(Value::test_string("- a=1 b=true\n")),
136            },
137            Example {
138                description: "Convert a list to JSON-in-KDL.",
139                example: "[1 2 3] | to kdl",
140                result: Some(Value::test_string("- 1 2 3\n")),
141            },
142            Example {
143                description: "Emit null/bool keywords as KDL v2.",
144                example: "[null false true] | to kdl --spec 2",
145                result: Some(Value::test_string("- #null #false #true\n")),
146            },
147            Example {
148                description: "Emit null/bool keywords as KDL v1.",
149                example: "[null false true] | to kdl --spec 1",
150                result: Some(Value::test_string("- null false true\n")),
151            },
152            Example {
153                description: "Round-trip KDL through node rows (default v2).",
154                example: "'node one; node two' | from kdl | to kdl",
155                result: Some(Value::test_string("node one\nnode two\n")),
156            },
157            Example {
158                description: "Round-trip a KDL v1 document; metadata keeps --spec 1 on to kdl.",
159                example: r#""item 1 enabled=true" | from kdl --spec 1 | to kdl"#,
160                result: Some(Value::test_string("item 1 enabled=true\n")),
161            },
162            Example {
163                description: "Round-trip a KDL v2 document with --spec 2 on both sides.",
164                example: r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl --spec 2"#,
165                result: Some(Value::test_string("item 1 enabled=#true\n")),
166            },
167            Example {
168                description: "Override metadata: parse as v1 but emit as v2.",
169                example: r#""item 1 enabled=true" | from kdl --spec 1 | to kdl --spec 2"#,
170                result: Some(Value::test_string("item 1 enabled=#true\n")),
171            },
172            Example {
173                description: "Serialize a closure as a string.",
174                example: "{|| 1 + 1} | to kdl --serialize",
175                result: Some(Value::test_string("- \"{|| 1 + 1}\"\n")),
176            },
177            Example {
178                description: "Emit Nushell filesize and duration with type annotations.",
179                example: "{size: 1kib, wait: 5sec} | to kdl",
180                result: Some(Value::test_string(
181                    "- size=(filesize)1024 wait=(duration)5000000000\n",
182                )),
183            },
184            Example {
185                description: "Emit a datetime as a (timestamp) annotation (RFC 3339 string).",
186                example: "{when: 2020-01-02T03:04:05+00:00} | to kdl",
187                result: Some(Value::test_string(
188                    "- when=(timestamp)\"2020-01-02T03:04:05+00:00\"\n",
189                )),
190            },
191            Example {
192                description: "Emit a cell-path with a (cell-path) annotation.",
193                example: "$.1.abc | to kdl",
194                result: Some(Value::test_string("- (cell-path)$.1.abc\n")),
195            },
196            Example {
197                description: "Emit a range with a (range) annotation.",
198                example: "1..3 | to kdl",
199                result: Some(Value::test_string("- (range)\"1..3\"\n")),
200            },
201            Example {
202                description: "Emit a glob with a (glob) annotation.",
203                example: r#""*.rs" | into glob | to kdl"#,
204                result: Some(Value::test_string("- (glob)*.rs\n")),
205            },
206            Example {
207                description: "Emit binary data as base64 with a (binary) annotation.",
208                example: "0x[01 02 03] | to kdl",
209                result: Some(Value::test_string("- (binary)AQID\n")),
210            },
211            Example {
212                description: "Round-trip annotated Nushell types through KDL.",
213                example: "{size: 1kib, wait: 5sec} | to kdl | from kdl --format jik",
214                result: Some(Value::test_record(record! {
215                    "size" => Value::test_filesize(1024),
216                    "wait" => Value::test_duration(5_000_000_000),
217                })),
218            },
219            Example {
220                description: "Round-trip a list of records (table-shaped data) as JSON-in-KDL.",
221                example: "[{a: 1}, {a: 2}] | to kdl | from kdl --format jik",
222                result: Some(Value::test_list(vec![
223                    Value::test_record(record! { "a" => Value::test_int(1) }),
224                    Value::test_record(record! { "a" => Value::test_int(2) }),
225                ])),
226            },
227        ]
228    }
229}
230
231#[cfg(test)]
232mod test {
233    use super::*;
234    use crate::formats::kdl::{
235        KdlMetadata, NonRoundtrip, document_to_string, node_rows_to_kdl_document,
236        value_to_jik_document,
237    };
238    use crate::{Get, Metadata};
239    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
240    use nu_protocol::PipelineMetadata;
241
242    fn eval_kdl(cmd: &str) -> Value {
243        let mut engine_state = Box::new(EngineState::new());
244        let delta = {
245            let mut working_set = StateWorkingSet::new(&engine_state);
246            working_set.add_decl(Box::new(crate::formats::FromKdl));
247            working_set.add_decl(Box::new(ToKdl));
248            working_set.add_decl(Box::new(Metadata {}));
249            working_set.add_decl(Box::new(Get {}));
250            working_set.render()
251        };
252        engine_state
253            .merge_delta(delta)
254            .expect("error merging delta");
255        eval_pipeline_without_terminal_expression(
256            cmd,
257            std::env::temp_dir().as_ref(),
258            &mut engine_state,
259        )
260        .expect("pipeline should succeed")
261    }
262
263    #[test]
264    fn test_examples() -> nu_test_support::Result {
265        nu_test_support::test().examples(ToKdl)
266    }
267
268    #[test]
269    fn jik_wraps_scalars_in_anon_node() {
270        let document =
271            value_to_jik_document(&Value::test_int(5), &NonRoundtrip::Error, Span::test_data())
272                .expect("scalar should serialize");
273
274        assert_eq!(document.to_string(), "- 5\n");
275    }
276
277    #[test]
278    fn jik_empty_list_and_record() {
279        let span = Span::test_data();
280        let list_doc = value_to_jik_document(&Value::test_list(vec![]), &NonRoundtrip::Error, span)
281            .expect("empty list");
282        assert_eq!(list_doc.to_string(), "(array)-\n");
283
284        let rec_doc =
285            value_to_jik_document(&Value::test_record(record! {}), &NonRoundtrip::Error, span)
286                .expect("empty record");
287        assert_eq!(rec_doc.to_string(), "(object)-\n");
288    }
289
290    #[test]
291    fn node_rows_round_trip_shape() {
292        let span = Span::test_data();
293        let rows = Value::test_list(vec![Value::test_record(record! {
294            "name" => Value::string("item", span),
295            "args" => Value::test_list(vec![Value::int(1, span)]),
296            "props" => Value::test_record(record! { "enabled" => Value::bool(true, span) }),
297            "children" => Value::test_list(vec![]),
298        })]);
299
300        let document =
301            node_rows_to_kdl_document(&rows, &NonRoundtrip::Error, span).expect("serialize");
302        assert_eq!(document.to_string(), "item 1 enabled=#true\n");
303    }
304
305    #[test]
306    fn nodes_format_rejects_plain_records() {
307        let span = Span::test_data();
308        let value = Value::test_record(record! {
309            "plain" => Value::int(7, span),
310        });
311
312        let err = node_rows_to_kdl_document(&value, &NonRoundtrip::Error, span)
313            .expect_err("should reject");
314        match err {
315            ShellError::UnsupportedInput { msg, .. } => {
316                assert!(
317                    msg.contains("nodes format") || msg.contains("node row"),
318                    "unexpected error message: {msg}"
319                );
320            }
321            other => panic!("expected UnsupportedInput, got {other:?}"),
322        }
323    }
324
325    #[test]
326    fn metadata_round_trip_defaults_format_and_spec() {
327        let span = Span::test_data();
328        let mut metadata = PipelineMetadata::default();
329        KdlMetadata {
330            format: KdlFormat::Nodes,
331            spec: KdlSpec::V1,
332        }
333        .write_to(&mut metadata, span);
334
335        let read = KdlMetadata::read_from(&metadata).expect("metadata");
336        assert_eq!(read.format, KdlFormat::Nodes);
337        assert_eq!(read.spec, KdlSpec::V1);
338    }
339
340    #[test]
341    fn from_kdl_marker_flows_to_to_kdl_command() {
342        // eval_pipeline_without_terminal_expression drops the last pipeline element,
343        // so end with `$in` (or any dummy) to keep the expression under test.
344        let result = eval_kdl(
345            "'node one; node two' | from kdl | to kdl | metadata | get content_type | $in",
346        );
347        assert_eq!(result, Value::test_string("application/x-kdl"));
348    }
349
350    #[test]
351    fn pipeline_to_kdl_spec_1_emits_bare_keywords() {
352        let result = eval_kdl("{a: 1, b: true} | to kdl --spec 1 | $in");
353        assert_eq!(result, Value::test_string("- a=1 b=true\n"));
354    }
355
356    #[test]
357    fn pipeline_to_kdl_spec_2_emits_hash_keywords() {
358        let result = eval_kdl("{a: 1, b: true} | to kdl --spec 2 | $in");
359        assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
360    }
361
362    #[test]
363    fn pipeline_default_spec_is_v2() {
364        let result = eval_kdl("{a: 1, b: true} | to kdl | $in");
365        assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
366    }
367
368    #[test]
369    fn pipeline_from_spec_1_metadata_keeps_v1_on_to_kdl() {
370        let result = eval_kdl(r#""item 1 enabled=true" | from kdl --spec 1 | to kdl | $in"#);
371        assert_eq!(result, Value::test_string("item 1 enabled=true\n"));
372    }
373
374    #[test]
375    fn pipeline_from_spec_2_metadata_keeps_v2_on_to_kdl() {
376        let result = eval_kdl(r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl | $in"#);
377        assert_eq!(result, Value::test_string("item 1 enabled=#true\n"));
378    }
379
380    #[test]
381    fn pipeline_explicit_spec_overrides_metadata() {
382        let to_v2 =
383            eval_kdl(r#""item 1 enabled=true" | from kdl --spec 1 | to kdl --spec 2 | $in"#);
384        assert_eq!(to_v2, Value::test_string("item 1 enabled=#true\n"));
385        let to_v1 =
386            eval_kdl(r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl --spec 1 | $in"#);
387        assert_eq!(to_v1, Value::test_string("item 1 enabled=true\n"));
388    }
389
390    #[test]
391    fn pipeline_list_keywords_follow_spec() {
392        assert_eq!(
393            eval_kdl("[null false true] | to kdl --spec 1 | $in"),
394            Value::test_string("- null false true\n")
395        );
396        assert_eq!(
397            eval_kdl("[null false true] | to kdl --spec 2 | $in"),
398            Value::test_string("- #null #false #true\n")
399        );
400    }
401
402    #[test]
403    fn pipeline_from_spec_1_and_2_decode_same_data() {
404        let v1 = eval_kdl(r#""node true false null" | from kdl --spec 1 | get 0.args | $in"#);
405        let v2 = eval_kdl(r#""node #true #false #null" | from kdl --spec 2 | get 0.args | $in"#);
406        assert_eq!(v1, v2);
407    }
408
409    #[test]
410    fn pipeline_from_v1_jik_and_to_v1_jik_round_trip() {
411        let result = eval_kdl(
412            "'- a=1 b=true' | from kdl --format jik --spec 1 | to kdl --format jik --spec 1 | $in",
413        );
414        assert_eq!(result, Value::test_string("- a=1 b=true\n"));
415    }
416
417    #[test]
418    fn pipeline_from_v2_jik_and_to_v2_jik_round_trip() {
419        let result = eval_kdl(
420            "'- a=1 b=#true' | from kdl --format jik --spec 2 | to kdl --format jik --spec 2 | $in",
421        );
422        assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
423    }
424
425    #[test]
426    fn pipeline_list_of_records_jik_round_trip() {
427        // Regression: multi-row tables used to be misread as objects (duplicate key '-').
428        let result = eval_kdl("[{a: 1}, {a: 2}] | to kdl | from kdl --format jik | $in");
429        let span = Span::test_data();
430        assert_eq!(
431            result,
432            Value::test_list(vec![
433                Value::test_record(record! { "a" => Value::int(1, span) }),
434                Value::test_record(record! { "a" => Value::int(2, span) }),
435            ])
436        );
437    }
438
439    #[test]
440    fn pipeline_jik_multi_anon_children_is_list() {
441        let result = eval_kdl("'- { - a=1; - a=2 }' | from kdl --format jik | $in");
442        let span = Span::test_data();
443        assert_eq!(
444            result,
445            Value::test_list(vec![
446                Value::test_record(record! { "a" => Value::int(1, span) }),
447                Value::test_record(record! { "a" => Value::int(2, span) }),
448            ])
449        );
450    }
451
452    #[test]
453    fn pipeline_jik_sole_anon_child_is_list_not_object() {
454        let result = eval_kdl("'- { - 1 }' | from kdl --format jik | $in");
455        assert_eq!(result, Value::test_list(vec![Value::test_int(1)]));
456    }
457
458    #[test]
459    fn pipeline_jik_object_key_dash_needs_annotation() {
460        let result = eval_kdl("'(object)- { - 1 }' | from kdl --format jik | $in");
461        let span = Span::test_data();
462        assert_eq!(
463            result,
464            Value::test_record(record! { "-" => Value::int(1, span) })
465        );
466    }
467
468    #[test]
469    fn document_to_string_respects_spec_for_node_rows() {
470        let span = Span::test_data();
471        let rows = Value::test_list(vec![Value::test_record(record! {
472            "name" => Value::string("item", span),
473            "args" => Value::test_list(vec![Value::int(1, span)]),
474            "props" => Value::test_record(record! { "enabled" => Value::bool(true, span) }),
475            "children" => Value::test_list(vec![]),
476        })]);
477        let doc = node_rows_to_kdl_document(&rows, &NonRoundtrip::Error, span).unwrap();
478        assert_eq!(
479            document_to_string(doc.clone(), KdlSpec::V1),
480            "item 1 enabled=true\n"
481        );
482        assert_eq!(
483            document_to_string(doc, KdlSpec::V2),
484            "item 1 enabled=#true\n"
485        );
486    }
487}