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 let canonical_node_rows = metadata_marks_canonical_kdl_rows(&metadata);
41 metadata.custom.remove(KDL_CANONICAL_METADATA_KEY);
44 let metadata = metadata.with_content_type(Some("application/x-kdl".to_owned()));
45
46 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 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 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 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 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}