Skip to main content

nu_command/formats/to/
kdl.rs

1use crate::formats::{KDL_CANONICAL_METADATA_KEY, KDL_CANONICAL_METADATA_VALUE};
2use kdl::{KdlDocument, KdlEntry, KdlIdentifier, KdlNode, KdlValue};
3use nu_engine::command_prelude::*;
4use nu_protocol::PipelineMetadata;
5
6#[derive(Clone)]
7pub struct ToKdl;
8
9impl Command for ToKdl {
10    fn name(&self) -> &str {
11        "to kdl"
12    }
13
14    fn signature(&self) -> Signature {
15        Signature::build("to kdl")
16            .input_output_types(vec![(Type::Any, Type::String)])
17            .switch(
18                "serialize",
19                "Serialize nushell types that cannot be deserialized.",
20                Some('s'),
21            )
22            .category(Category::Formats)
23    }
24
25    fn description(&self) -> &str {
26        "Converts table data into KDL text."
27    }
28
29    fn run(
30        &self,
31        engine_state: &EngineState,
32        stack: &mut Stack,
33        call: &Call,
34        mut input: PipelineData,
35    ) -> Result<PipelineData, ShellError> {
36        let call_span = input.span().unwrap_or(call.head);
37        let mut metadata = input.take_metadata().unwrap_or_default();
38        // Consume the explicit round-trip marker written by `from kdl`.
39        // We require this marker to opt into canonical node-row interpretation.
40        let canonical_node_rows = metadata_marks_canonical_kdl_rows(&metadata);
41        // Remove the internal marker from outgoing metadata so it does not leak
42        // beyond this conversion boundary.
43        metadata.custom.remove(KDL_CANONICAL_METADATA_KEY);
44        let metadata = metadata.with_content_type(Some("application/x-kdl".to_owned()));
45
46        // get args
47        let serialize_types = call.has_flag(engine_state, stack, "serialize")?;
48
49        let value = input.into_value(call_span)?;
50        let output_string = value_to_kdl_document(
51            engine_state,
52            &value,
53            canonical_node_rows,
54            serialize_types,
55            call_span,
56        )?
57        .to_string();
58
59        Ok(output_string
60            .into_value(call_span)
61            .into_pipeline_data_with_metadata(metadata))
62    }
63
64    fn examples(&self) -> Vec<Example<'_>> {
65        vec![
66            Example {
67                description: "Convert yaml file to kdl file",
68                example: "{this: that list: [1 2 3 {bool: true} {this: should be: a-block}]} | to yaml | from yaml | to kdl",
69                result: Some(Value::test_string(
70                    "this that\nlist 1 2 3 bool=#true {\n    this should\n    be a-block\n}\n",
71                )),
72            },
73            Example {
74                description: "Convert nu record to kdl",
75                example: "{one: [{one: two, 1: 2} {three: 3} [1 2 3] 4 5 6 {bool: true}] } | to kdl",
76                result: Some(Value::test_string(
77                    "one three=3 1 2 3 4 5 6 bool=#true {\n    one two\n    \"1\" 2\n}\n",
78                )),
79            },
80            Example {
81                description: "Convert nu list to kdl string",
82                example: "[1 2 3] | to kdl",
83                result: Some(Value::test_string("root 1 2 3\n")),
84            },
85            Example {
86                description: "Convert nu closure to kdl string",
87                example: "{2: {|| 1 + 1} } | to kdl --serialize",
88                result: Some(Value::test_string("\"2\" \"{|| 1 + 1}\"\n")),
89            },
90            Example {
91                description: "Round-trip KDL through canonical node rows.",
92                example: "'node one; node two' | from kdl | to kdl",
93                result: Some(Value::test_string("node one\nnode two\n")),
94            },
95        ]
96    }
97}
98
99fn metadata_marks_canonical_kdl_rows(metadata: &PipelineMetadata) -> bool {
100    // Canonical KDL mode is opt-in and carried via pipeline metadata from
101    // `from kdl`, so ordinary records with `name/args/props/children` keys
102    // are not reinterpreted accidentally.
103    matches!(
104        metadata.custom.get(KDL_CANONICAL_METADATA_KEY),
105        Some(Value::String { val, .. }) if val == KDL_CANONICAL_METADATA_VALUE
106    )
107}
108
109fn value_is_canonical_node_row(value: &Value) -> bool {
110    let Value::Record { val, .. } = value else {
111        return false;
112    };
113
114    record_is_canonical_node_row(val)
115}
116
117fn record_is_canonical_node_row(record: &Record) -> bool {
118    record.len() == 4
119        && matches!(record.get("name"), Some(Value::String { .. }))
120        && record
121            .get("args")
122            .is_some_and(|value| value.as_list().is_ok())
123        && record
124            .get("props")
125            .is_some_and(|value| value.as_record().is_ok())
126        && record
127            .get("children")
128            .is_some_and(|value| value.as_list().is_ok())
129}
130
131fn list_is_canonical_node_rows(rows: &[Value]) -> bool {
132    rows.iter().all(value_is_canonical_node_row)
133}
134
135fn convert_record_into_formatted_kdl_document_recursively(
136    engine_state: &EngineState,
137    record: &Record,
138    serialize_types: bool,
139    call_span: Span,
140) -> Result<KdlDocument, ShellError> {
141    let mut kdl_document = KdlDocument::new();
142
143    for (key, value) in record.iter() {
144        let mut node = KdlNode::new(identifier_for_key(key));
145        append_value_to_kdl_node(engine_state, &mut node, value, serialize_types, call_span)?;
146
147        kdl_document.nodes_mut().push(node);
148    }
149
150    // format the document before returning it
151    kdl_document.autoformat();
152    Ok(kdl_document)
153}
154
155fn convert_list_into_entries_of_kdl_node_recursively(
156    engine_state: &EngineState,
157    node: &mut KdlNode,
158    list: &[Value],
159    serialize_types: bool,
160    call_span: Span,
161) -> Result<(), ShellError> {
162    for value in list {
163        match value {
164            Value::Record { val, .. } => {
165                append_record_to_kdl_node(engine_state, node, val, serialize_types, call_span)?;
166            }
167            Value::List { vals, .. } => {
168                convert_list_into_entries_of_kdl_node_recursively(
169                    engine_state,
170                    node,
171                    vals,
172                    serialize_types,
173                    call_span,
174                )?;
175            }
176            val => {
177                ensure_closure_is_serializable(val, serialize_types, call_span)?;
178                node.push(convert_nu_value_to_kdl_value(engine_state, call_span, val)?);
179            }
180        };
181    }
182
183    Ok(())
184}
185
186fn value_to_kdl_document(
187    engine_state: &EngineState,
188    value: &Value,
189    canonical_node_rows: bool,
190    serialize_types: bool,
191    call_span: Span,
192) -> Result<KdlDocument, ShellError> {
193    match value {
194        // Only enter canonical mode when both metadata and value shape agree.
195        // This guards against stale metadata after downstream transformations.
196        Value::Record { val, .. } if canonical_node_rows && record_is_canonical_node_row(val) => {
197            canonical_node_rows_to_kdl_document(
198                std::slice::from_ref(value),
199                engine_state,
200                serialize_types,
201                call_span,
202            )
203        }
204        Value::Record { val, .. } => convert_record_into_formatted_kdl_document_recursively(
205            engine_state,
206            val,
207            serialize_types,
208            call_span,
209        ),
210        Value::List { vals, .. } if canonical_node_rows && list_is_canonical_node_rows(vals) => {
211            canonical_node_rows_to_kdl_document(vals, engine_state, serialize_types, call_span)
212        }
213        Value::List { vals, .. } => {
214            let mut kdl_document = KdlDocument::new();
215            let mut node = KdlNode::new("root");
216            convert_list_into_entries_of_kdl_node_recursively(
217                engine_state,
218                &mut node,
219                vals,
220                serialize_types,
221                call_span,
222            )?;
223            kdl_document.nodes_mut().push(node);
224            kdl_document.autoformat();
225            Ok(kdl_document)
226        }
227        val => {
228            ensure_closure_is_serializable(val, serialize_types, call_span)?;
229            let mut kdl_document = KdlDocument::new();
230            let mut node = KdlNode::new("root");
231            node.push(convert_nu_value_to_kdl_value(engine_state, call_span, val)?);
232            kdl_document.nodes_mut().push(node);
233            kdl_document.autoformat();
234            Ok(kdl_document)
235        }
236    }
237}
238
239fn canonical_node_rows_to_kdl_document(
240    rows: &[Value],
241    engine_state: &EngineState,
242    serialize_types: bool,
243    call_span: Span,
244) -> Result<KdlDocument, ShellError> {
245    let mut kdl_document = KdlDocument::new();
246
247    for row in rows {
248        let Value::Record { val, .. } = row else {
249            return Err(ShellError::UnsupportedInput {
250                msg: "canonical node rows must be records".into(),
251                input: "value originates from here".into(),
252                msg_span: call_span,
253                input_span: row.span(),
254            });
255        };
256
257        kdl_document
258            .nodes_mut()
259            .push(convert_canonical_node_row_to_kdl_node(
260                engine_state,
261                val,
262                serialize_types,
263                call_span,
264            )?);
265    }
266
267    kdl_document.autoformat();
268    Ok(kdl_document)
269}
270
271fn convert_canonical_node_row_to_kdl_node(
272    engine_state: &EngineState,
273    row: &Record,
274    serialize_types: bool,
275    call_span: Span,
276) -> Result<KdlNode, ShellError> {
277    let name = node_row_string_field(row, "name", call_span)?;
278    let args = node_row_list_field(row, "args", call_span)?;
279    let props = node_row_record_field(row, "props", call_span)?;
280    let children = node_row_list_field(row, "children", call_span)?;
281
282    let mut node = KdlNode::new(identifier_for_key(name));
283
284    for arg in args {
285        ensure_closure_is_serializable(arg, serialize_types, call_span)?;
286        node.push(convert_nu_value_to_kdl_value(engine_state, call_span, arg)?);
287    }
288
289    for (key, value) in props.iter() {
290        ensure_closure_is_serializable(value, serialize_types, call_span)?;
291        node.push(KdlEntry::new_prop(
292            identifier_for_key(key),
293            convert_nu_value_to_kdl_value(engine_state, call_span, value)?,
294        ));
295    }
296
297    if !children.is_empty() {
298        let child_document = canonical_node_rows_to_kdl_document(
299            children,
300            engine_state,
301            serialize_types,
302            call_span,
303        )?;
304        merge_children(&mut node, child_document, call_span, children[0].span())?;
305    }
306
307    Ok(node)
308}
309
310fn node_row_string_field<'a>(
311    row: &'a Record,
312    field: &str,
313    call_span: Span,
314) -> Result<&'a str, ShellError> {
315    let value = node_row_field(row, field, call_span)?;
316    value.as_str().map_err(|_| ShellError::UnsupportedInput {
317        msg: format!("canonical node row field '{field}' must be a string"),
318        input: "value originates from here".into(),
319        msg_span: call_span,
320        input_span: value.span(),
321    })
322}
323
324fn node_row_list_field<'a>(
325    row: &'a Record,
326    field: &str,
327    call_span: Span,
328) -> Result<&'a [Value], ShellError> {
329    let value = node_row_field(row, field, call_span)?;
330    value.as_list().map_err(|_| ShellError::UnsupportedInput {
331        msg: format!("canonical node row field '{field}' must be a list"),
332        input: "value originates from here".into(),
333        msg_span: call_span,
334        input_span: value.span(),
335    })
336}
337
338fn node_row_record_field<'a>(
339    row: &'a Record,
340    field: &str,
341    call_span: Span,
342) -> Result<&'a Record, ShellError> {
343    let value = node_row_field(row, field, call_span)?;
344    value.as_record().map_err(|_| ShellError::UnsupportedInput {
345        msg: format!("canonical node row field '{field}' must be a record"),
346        input: "value originates from here".into(),
347        msg_span: call_span,
348        input_span: value.span(),
349    })
350}
351
352fn node_row_field<'a>(
353    row: &'a Record,
354    field: &str,
355    call_span: Span,
356) -> Result<&'a Value, ShellError> {
357    row.get(field).ok_or_else(|| ShellError::UnsupportedInput {
358        msg: format!("canonical node row is missing '{field}' field"),
359        input: "value originates from here".into(),
360        msg_span: call_span,
361        input_span: call_span,
362    })
363}
364
365fn append_value_to_kdl_node(
366    engine_state: &EngineState,
367    node: &mut KdlNode,
368    value: &Value,
369    serialize_types: bool,
370    call_span: Span,
371) -> Result<(), ShellError> {
372    match value {
373        Value::Record { val, .. } => {
374            append_record_to_kdl_node(engine_state, node, val, serialize_types, call_span)
375        }
376        Value::List { vals, .. } => convert_list_into_entries_of_kdl_node_recursively(
377            engine_state,
378            node,
379            vals,
380            serialize_types,
381            call_span,
382        ),
383        val => {
384            ensure_closure_is_serializable(val, serialize_types, call_span)?;
385            node.push(convert_nu_value_to_kdl_value(engine_state, call_span, val)?);
386            Ok(())
387        }
388    }
389}
390
391fn append_record_to_kdl_node(
392    engine_state: &EngineState,
393    node: &mut KdlNode,
394    record: &Record,
395    serialize_types: bool,
396    call_span: Span,
397) -> Result<(), ShellError> {
398    let entries = record.iter().collect::<Vec<_>>();
399
400    if let [(key, value)] = entries.as_slice()
401        && value.as_record().is_err()
402        && value.as_list().is_err()
403    {
404        ensure_closure_is_serializable(value, serialize_types, call_span)?;
405        node.push(KdlEntry::new_prop(
406            identifier_for_key(key),
407            convert_nu_value_to_kdl_value(engine_state, call_span, value)?,
408        ));
409        return Ok(());
410    }
411
412    let children = convert_record_into_formatted_kdl_document_recursively(
413        engine_state,
414        record,
415        serialize_types,
416        call_span,
417    )?;
418    merge_children(
419        node,
420        children,
421        call_span,
422        entries.first().map_or(call_span, |(_, value)| value.span()),
423    )
424}
425
426fn merge_children(
427    node: &mut KdlNode,
428    mut children: KdlDocument,
429    _call_span: Span,
430    _input_span: Span,
431) -> Result<(), ShellError> {
432    node.ensure_children()
433        .nodes_mut()
434        .append(children.nodes_mut());
435    Ok(())
436}
437
438fn identifier_for_key(key: &str) -> KdlIdentifier {
439    let mut identifier = KdlIdentifier::from(key.to_owned());
440    identifier.clear_format();
441    identifier
442}
443
444fn ensure_closure_is_serializable(
445    value: &Value,
446    serialize_types: bool,
447    call_span: Span,
448) -> Result<(), ShellError> {
449    if value.as_closure().is_ok() && !serialize_types {
450        return Err(ShellError::UnsupportedInput {
451            msg: "closures are currently not deserializable (use --serialize to serialize as a string)".into(),
452            input: "value originates from here".into(),
453            msg_span: call_span,
454            input_span: value.span(),
455        });
456    }
457
458    Ok(())
459}
460
461fn convert_nu_value_to_kdl_value(
462    engine_state: &EngineState,
463    span: Span,
464    value: &Value,
465) -> Result<KdlValue, ShellError> {
466    match value {
467        Value::Bool { val, .. } => Ok(KdlValue::Bool(*val)),
468        Value::Int { val, .. } => Ok(KdlValue::Integer(*val as i128)),
469        Value::Float { val, .. } => Ok(KdlValue::Float(*val)),
470        Value::Filesize { val, .. } => Ok(KdlValue::String(val.to_string())),
471        Value::Duration { val, .. } => Ok(KdlValue::String(val.to_string())),
472        Value::Date { val, .. } => Ok(KdlValue::String(val.to_string())),
473        Value::Range { val, .. } => Ok(KdlValue::String(val.to_string())),
474        Value::String { val, .. } => Ok(KdlValue::String(val.clone())),
475        Value::Glob { val, .. } => Ok(KdlValue::String(val.clone())),
476        Value::Closure { val, .. } => Ok(KdlValue::String(
477            val.coerce_into_string(engine_state, span)?.to_string(),
478        )),
479        Value::Nothing { .. } => Ok(KdlValue::Null),
480        Value::Binary { val, .. } => Ok(KdlValue::String(format!("{val:?}"))),
481        Value::CellPath { val, .. } => Ok(KdlValue::String(val.to_string())),
482        Value::Custom { val, .. } => Ok(KdlValue::String(format!("<{}>", val.type_name()))),
483        Value::Error { error, .. } => Err(*(error.clone())),
484        _ => Err(ShellError::UnsupportedInput {
485            msg: "value cannot be stringified".to_owned(),
486            input: value.get_type().to_string(),
487            msg_span: span,
488            input_span: value.span(),
489        }),
490    }
491}
492
493#[cfg(test)]
494mod test {
495    use super::*;
496    use crate::{Get, Metadata};
497    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
498
499    #[test]
500    fn test_examples() -> nu_test_support::Result {
501        nu_test_support::test().examples(ToKdl)
502    }
503
504    #[test]
505    fn top_level_scalars_are_wrapped_in_root_node() {
506        let engine_state = EngineState::default();
507        let result = value_to_kdl_document(
508            &engine_state,
509            &Value::test_int(5),
510            false,
511            false,
512            Span::test_data(),
513        )
514        .expect("scalar document should serialize");
515
516        assert_eq!(result.to_string(), "root 5\n");
517    }
518
519    #[test]
520    fn canonical_node_rows_round_trip_to_document_shape() {
521        let engine_state = EngineState::default();
522        let span = Span::test_data();
523        let rows = Value::test_list(vec![Value::test_record(record! {
524            "name" => Value::string("item", span),
525            "args" => Value::test_list(vec![Value::int(1, span)]),
526            "props" => Value::test_record(record! { "enabled" => Value::bool(true, span) }),
527            "children" => Value::test_list(vec![]),
528        })]);
529
530        let result = value_to_kdl_document(&engine_state, &rows, true, false, span)
531            .expect("canonical rows should serialize");
532
533        assert_eq!(result.to_string(), "item 1 enabled=#true\n");
534    }
535
536    #[test]
537    fn list_conversion_merges_multiple_child_blocks() {
538        let engine_state = EngineState::default();
539        let span = Span::test_data();
540        let value = Value::test_list(vec![
541            Value::test_record(
542                record! { "a" => Value::test_record(record! { "b" => Value::int(1, span) }) },
543            ),
544            Value::test_record(
545                record! { "c" => Value::test_record(record! { "d" => Value::int(2, span) }) },
546            ),
547        ]);
548
549        let document = value_to_kdl_document(&engine_state, &value, false, false, span)
550            .expect("multiple child blocks should merge");
551
552        assert_eq!(
553            document.to_string(),
554            "root {
555    a b=1
556    c d=2
557}
558"
559        );
560    }
561
562    #[test]
563    fn shape_matching_record_is_not_treated_as_canonical_without_metadata() {
564        let engine_state = EngineState::default();
565        let span = Span::test_data();
566        let value = Value::test_record(record! {
567            "name" => Value::string("item", span),
568            "args" => Value::test_list(vec![]),
569            "props" => Value::test_record(record! {}),
570            "children" => Value::test_list(vec![]),
571        });
572
573        let document = value_to_kdl_document(&engine_state, &value, false, false, span)
574            .expect("plain records should use normal record serialization");
575
576        assert_eq!(
577            document.to_string(),
578            "name item\nargs\nprops {\n}\nchildren\n"
579        );
580    }
581
582    #[test]
583    fn metadata_marker_enables_canonical_row_serialization() {
584        let span = Span::test_data();
585        let mut metadata = PipelineMetadata::default();
586        // `from kdl` writes this key/value pair to mark that values are
587        // canonical KDL node rows and should be interpreted in round-trip mode.
588        metadata.custom.insert(
589            KDL_CANONICAL_METADATA_KEY,
590            Value::string(KDL_CANONICAL_METADATA_VALUE, span),
591        );
592
593        assert!(metadata_marks_canonical_kdl_rows(&metadata));
594    }
595
596    #[test]
597    fn from_kdl_marker_flows_to_to_kdl_command() {
598        let mut engine_state = Box::new(EngineState::new());
599        let delta = {
600            let mut working_set = StateWorkingSet::new(&engine_state);
601
602            working_set.add_decl(Box::new(crate::formats::FromKdl));
603            working_set.add_decl(Box::new(ToKdl));
604            working_set.add_decl(Box::new(Metadata {}));
605            working_set.add_decl(Box::new(Get {}));
606
607            working_set.render()
608        };
609
610        engine_state
611            .merge_delta(delta)
612            .expect("error merging delta");
613
614        let cmd = "'node one; node two' | from kdl | to kdl | metadata | get content_type | $in";
615        let result = eval_pipeline_without_terminal_expression(
616            cmd,
617            std::env::temp_dir().as_ref(),
618            &mut engine_state,
619        )
620        .expect("pipeline should succeed");
621
622        assert_eq!(result, Value::test_string("application/x-kdl"));
623    }
624
625    #[test]
626    fn canonical_metadata_constants_define_round_trip_contract() {
627        let span = Span::test_data();
628        let mut metadata = PipelineMetadata::default();
629
630        metadata.custom.insert(
631            KDL_CANONICAL_METADATA_KEY,
632            Value::string(KDL_CANONICAL_METADATA_VALUE, span),
633        );
634
635        assert!(metadata_marks_canonical_kdl_rows(&metadata));
636
637        metadata.custom.insert(
638            KDL_CANONICAL_METADATA_KEY,
639            Value::string("wrong_version", span),
640        );
641
642        assert!(!metadata_marks_canonical_kdl_rows(&metadata));
643    }
644
645    #[test]
646    fn stale_canonical_metadata_falls_back_to_regular_record_serialization() {
647        let engine_state = EngineState::default();
648        let span = Span::test_data();
649        let value = Value::test_record(record! {
650            "plain" => Value::int(7, span),
651        });
652
653        let document = value_to_kdl_document(&engine_state, &value, true, false, span)
654            .expect("stale canonical marker should not force canonical conversion");
655
656        assert_eq!(document.to_string(), "plain 7\n");
657    }
658
659    #[test]
660    fn stale_canonical_metadata_falls_back_to_regular_list_serialization() {
661        let engine_state = EngineState::default();
662        let span = Span::test_data();
663        let value = Value::test_list(vec![Value::test_record(record! {
664            "plain" => Value::int(7, span),
665        })]);
666
667        let document = value_to_kdl_document(&engine_state, &value, true, false, span)
668            .expect("stale canonical marker should not force canonical conversion");
669
670        assert_eq!(document.to_string(), "root plain=7\n");
671    }
672}