Skip to main content

nu_command/formats/from/
kdl.rs

1use crate::formats::kdl::{
2    KdlFormat, KdlMetadata, KdlSpec, jik_document_to_value, nodes_document_to_value,
3    parse_kdl_document,
4};
5use chrono::TimeZone;
6use nu_engine::command_prelude::*;
7use std::str::FromStr;
8
9#[derive(Clone)]
10pub struct FromKdl;
11
12impl Command for FromKdl {
13    fn name(&self) -> &str {
14        "from kdl"
15    }
16
17    fn description(&self) -> &str {
18        "Convert KDL text into structured data."
19    }
20
21    fn search_terms(&self) -> Vec<&str> {
22        vec!["convert", "import", "config"]
23    }
24
25    fn signature(&self) -> Signature {
26        Signature::build("from kdl")
27            .input_output_types(vec![(Type::String, Type::Any)])
28            .param(
29                Flag::new("spec")
30                    .arg(SyntaxShape::Int)
31                    .desc("KDL language version (1 or 2 (default)).")
32                    .completion(Completion::new_list(&["1", "2"])),
33            )
34            .param(
35                Flag::new("format")
36                    .arg(SyntaxShape::String)
37                    .desc(
38                        "Data model: 'nodes' (default) for KDL AST rows, or 'jik' for JSON-in-KDL values.",
39                    )
40                    .completion(Completion::new_list(&["nodes", "jik"])),
41            )
42            .switch(
43                "ignore-types",
44                "Ignore type annotations (return base KDL types only).",
45                None,
46            )
47            .category(Category::Formats)
48    }
49
50    fn examples(&self) -> Vec<Example<'_>> {
51        let span = Span::unknown();
52
53        vec![
54            Example {
55                example: r#""node attr=1 attr2=#true {bloc}" | from kdl"#,
56                description: "Convert KDL to node rows (default format, KDL v2).",
57                result: Some(Value::test_list(vec![Value::test_record(record! {
58                    "name" => Value::string("node", span),
59                    "args" => Value::test_list(vec![]),
60                    "props" => Value::test_record(record! {
61                        "attr" => 1.into_value(span),
62                        "attr2" => true.into_value(span),
63                    }),
64                    "children" => Value::test_list(vec![Value::test_record(record! {
65                        "name" => Value::string("bloc", span),
66                        "args" => Value::test_list(vec![]),
67                        "props" => Value::test_record(record! {}),
68                        "children" => Value::test_list(vec![]),
69                    })]),
70                })])),
71            },
72            Example {
73                description: "Parse a package-style KDL document into node rows.",
74                example: r#"'package { name nu; version 0.1; description "new type of shell" }' | from kdl"#,
75                result: Some(Value::test_list(vec![Value::test_record(record! {
76                    "name" => Value::string("package", span),
77                    "args" => Value::test_list(vec![]),
78                    "props" => Value::test_record(record! {}),
79                    "children" => Value::test_list(vec![
80                        Value::test_record(record! {
81                            "name" => Value::string("name", span),
82                            "args" => Value::test_list(vec![Value::string("nu", span)]),
83                            "props" => Value::test_record(record! {}),
84                            "children" => Value::test_list(vec![]),
85                        }),
86                        Value::test_record(record! {
87                            "name" => Value::string("version", span),
88                            "args" => Value::test_list(vec![Value::float(0.1, span)]),
89                            "props" => Value::test_record(record! {}),
90                            "children" => Value::test_list(vec![]),
91                        }),
92                        Value::test_record(record! {
93                            "name" => Value::string("description", span),
94                            "args" => Value::test_list(vec![Value::string("new type of shell", span)]),
95                            "props" => Value::test_record(record! {}),
96                            "children" => Value::test_list(vec![]),
97                        }),
98                    ]),
99                })])),
100            },
101            Example {
102                description: "Parse JSON-in-KDL (v2 keywords use #true / #null).",
103                example: "'- a=1 b=#true' | from kdl --format jik",
104                result: Some(Value::test_record(record! {
105                    "a" => Value::int(1, span),
106                    "b" => Value::bool(true, span),
107                })),
108            },
109            Example {
110                description: "Parse KDL v2 keyword arguments with --spec 2 (default).",
111                example: r#""node #true #false #null" | from kdl --spec 2"#,
112                result: Some(Value::test_list(vec![Value::test_record(record! {
113                    "name" => Value::string("node", span),
114                    "args" => Value::test_list(vec![
115                        Value::bool(true, span),
116                        Value::bool(false, span),
117                        Value::nothing(span),
118                    ]),
119                    "props" => Value::test_record(record! {}),
120                    "children" => Value::test_list(vec![]),
121                })])),
122            },
123            Example {
124                description: "Parse KDL v1 keyword arguments with --spec 1 (bare true/false/null).",
125                example: r#""node true false null" | from kdl --spec 1"#,
126                result: Some(Value::test_list(vec![Value::test_record(record! {
127                    "name" => Value::string("node", span),
128                    "args" => Value::test_list(vec![
129                        Value::bool(true, span),
130                        Value::bool(false, span),
131                        Value::nothing(span),
132                    ]),
133                    "props" => Value::test_record(record! {}),
134                    "children" => Value::test_list(vec![]),
135                })])),
136            },
137            Example {
138                description: "Parse a KDL v1 property boolean with --spec 1.",
139                example: r#""item 1 enabled=true" | from kdl --spec 1"#,
140                result: Some(Value::test_list(vec![Value::test_record(record! {
141                    "name" => Value::string("item", span),
142                    "args" => Value::test_list(vec![Value::int(1, span)]),
143                    "props" => Value::test_record(record! {
144                        "enabled" => Value::bool(true, span),
145                    }),
146                    "children" => Value::test_list(vec![]),
147                })])),
148            },
149            Example {
150                description: "Parse a KDL v2 property boolean with --spec 2.",
151                example: r#""item 1 enabled=#true" | from kdl --spec 2"#,
152                result: Some(Value::test_list(vec![Value::test_record(record! {
153                    "name" => Value::string("item", span),
154                    "args" => Value::test_list(vec![Value::int(1, span)]),
155                    "props" => Value::test_record(record! {
156                        "enabled" => Value::bool(true, span),
157                    }),
158                    "children" => Value::test_list(vec![]),
159                })])),
160            },
161            Example {
162                description: "Parse JSON-in-KDL written in KDL v1 keyword style.",
163                example: "'- a=1 b=true c=null' | from kdl --format jik --spec 1",
164                result: Some(Value::test_record(record! {
165                    "a" => Value::int(1, span),
166                    "b" => Value::bool(true, span),
167                    "c" => Value::nothing(span),
168                })),
169            },
170            Example {
171                description: "Duplicate sibling node names are preserved in-order.",
172                example: r#""node one; node two" | from kdl"#,
173                result: Some(Value::test_list(vec![
174                    Value::test_record(record! {
175                        "name" => Value::string("node", span),
176                        "args" => Value::test_list(vec![Value::string("one", span)]),
177                        "props" => Value::test_record(record! {}),
178                        "children" => Value::test_list(vec![]),
179                    }),
180                    Value::test_record(record! {
181                        "name" => Value::string("node", span),
182                        "args" => Value::test_list(vec![Value::string("two", span)]),
183                        "props" => Value::test_record(record! {}),
184                        "children" => Value::test_list(vec![]),
185                    }),
186                ])),
187            },
188            Example {
189                description: "Promote Nushell type annotations on node arguments (filesize, duration).",
190                example: r#""node (filesize)1024 (duration)5000000000" | from kdl"#,
191                result: Some(Value::test_list(vec![Value::test_record(record! {
192                    "name" => Value::string("node", span),
193                    "args" => Value::test_list(vec![
194                        Value::test_filesize(1024),
195                        Value::test_duration(5_000_000_000),
196                    ]),
197                    "props" => Value::test_record(record! {}),
198                    "children" => Value::test_list(vec![]),
199                })])),
200            },
201            Example {
202                description: "Parse JSON-in-KDL with filesize, duration, and datetime (timestamp) annotations.",
203                example: r#"'- size=(filesize)1024 wait=(duration)5000000000 when=(timestamp)"2020-01-02T03:04:05+00:00"' | from kdl --format jik"#,
204                result: Some(Value::test_record(record! {
205                    "size" => Value::test_filesize(1024),
206                    "wait" => Value::test_duration(5_000_000_000),
207                    "when" => Value::test_date(
208                        chrono::FixedOffset::east_opt(0)
209                            .expect("offset")
210                            .with_ymd_and_hms(2020, 1, 2, 3, 4, 5)
211                            .unwrap()
212                    ),
213                })),
214            },
215            Example {
216                description: "Accept (datetime) as an alias for (timestamp) when parsing.",
217                example: r#"'- when=(datetime)"2020-01-02T03:04:05+00:00"' | from kdl --format jik"#,
218                result: Some(Value::test_record(record! {
219                    "when" => Value::test_date(
220                        chrono::FixedOffset::east_opt(0)
221                            .expect("offset")
222                            .with_ymd_and_hms(2020, 1, 2, 3, 4, 5)
223                            .unwrap()
224                    ),
225                })),
226            },
227            Example {
228                description: "Parse cell-path, range, glob, and binary type annotations.",
229                example: r#"'- path=(cell-path)$.1.abc span=(range)"1..3" pat=(glob)*.rs blob=(binary)AQID' | from kdl --format jik"#,
230                result: Some(Value::test_record(record! {
231                    "path" => Value::test_cell_path(nu_protocol::ast::CellPath {
232                        members: vec![
233                            nu_protocol::ast::PathMember::test_int(1, false),
234                            nu_protocol::ast::PathMember::test_string(
235                                "abc",
236                                false,
237                                nu_protocol::casing::Casing::Sensitive,
238                            ),
239                        ],
240                    }),
241                    "span" => Value::test_range(
242                        nu_protocol::Range::from_str("1..3").expect("range")
243                    ),
244                    "pat" => Value::test_glob("*.rs"),
245                    "blob" => Value::test_binary(vec![1, 2, 3]),
246                })),
247            },
248            Example {
249                description: "Ignore type annotations and keep base KDL types with --ignore-types.",
250                example: "'- size=(filesize)1024' | from kdl --format jik --ignore-types",
251                result: Some(Value::test_record(record! {
252                    "size" => Value::test_int(1024),
253                })),
254            },
255            Example {
256                description: "JiK: multiple '-' children are a list, not an object with duplicate keys.",
257                example: "'- { - a=1; - a=2 }' | from kdl --format jik",
258                result: Some(Value::test_list(vec![
259                    Value::test_record(record! { "a" => Value::int(1, span) }),
260                    Value::test_record(record! { "a" => Value::int(2, span) }),
261                ])),
262            },
263            Example {
264                description: "JiK: a sole '-' child is still a one-element list unless annotated (object).",
265                example: "'- { - 1 }' | from kdl --format jik",
266                result: Some(Value::test_list(vec![Value::int(1, span)])),
267            },
268            Example {
269                description: "JiK: object with key '-' must use the (object) annotation.",
270                example: "'(object)- { - 1 }' | from kdl --format jik",
271                result: Some(Value::test_record(record! {
272                    "-" => Value::int(1, span),
273                })),
274            },
275        ]
276    }
277
278    fn run(
279        &self,
280        engine_state: &EngineState,
281        stack: &mut Stack,
282        call: &Call,
283        mut input: PipelineData,
284    ) -> Result<PipelineData, ShellError> {
285        let span = input.span().unwrap_or(call.head);
286        let mut metadata = input
287            .take_metadata()
288            .unwrap_or_default()
289            .with_content_type(None);
290
291        let kdl_string = input.collect_string_strict(span)?;
292
293        let spec = match call.get_flag::<i64>(engine_state, stack, "spec")? {
294            Some(n) => {
295                let flag_span = call.get_flag_span(stack, "spec").unwrap_or(call.head);
296                KdlSpec::from_i64(n, flag_span)?
297            }
298            None => KdlSpec::default(),
299        };
300
301        let format = match call.get_flag::<String>(engine_state, stack, "format")? {
302            Some(s) => {
303                let flag_span = call.get_flag_span(stack, "format").unwrap_or(call.head);
304                KdlFormat::parse(&s, flag_span)?
305            }
306            None => KdlFormat::Nodes,
307        };
308
309        let ignore_types = call.has_flag(engine_state, stack, "ignore-types")?;
310
311        let document = parse_kdl_document(&kdl_string.0, spec, span)?;
312
313        let value = match format {
314            KdlFormat::Nodes => nodes_document_to_value(&document, span, ignore_types)?,
315            KdlFormat::Jik => jik_document_to_value(&document, span, ignore_types)?,
316        };
317
318        KdlMetadata { format, spec }.write_to(&mut metadata, span);
319
320        Ok(value.into_pipeline_data_with_metadata(Some(metadata)))
321    }
322}
323
324#[cfg(test)]
325mod test {
326    use super::*;
327    use crate::formats::kdl::{kdl_diagnostics_message, parse_kdl_document};
328    use kdl::KdlDocument;
329    use nu_protocol::shell_error::generic::GenericError;
330
331    fn node_name(row: &Value) -> &str {
332        row.as_record()
333            .ok()
334            .and_then(|record| record.get("name"))
335            .and_then(|value| value.as_str().ok())
336            .expect("row should contain string name")
337    }
338
339    #[test]
340    fn test_examples() -> nu_test_support::Result {
341        nu_test_support::test().examples(FromKdl)
342    }
343
344    #[test]
345    fn duplicate_sibling_names_are_preserved_in_order() {
346        let span = Span::test_data();
347        let kdl_document = KdlDocument::parse("node one\nnode two\nnode three")
348            .expect("failed to parse duplicate sibling document");
349
350        let output =
351            nodes_document_to_value(&kdl_document, span, false).expect("conversion failed");
352        let output_rows = output.as_list().expect("list");
353
354        assert_eq!(output_rows.len(), 3);
355        assert_eq!(node_name(&output_rows[0]), "node");
356        assert_eq!(node_name(&output_rows[1]), "node");
357        assert_eq!(node_name(&output_rows[2]), "node");
358    }
359
360    #[test]
361    fn duplicate_properties_use_at_suffix() {
362        let span = Span::test_data();
363        let kdl_document = KdlDocument::parse("node attr=1 attr=2")
364            .expect("failed to parse duplicate property document");
365
366        let output =
367            nodes_document_to_value(&kdl_document, span, false).expect("conversion failed");
368        let props = output
369            .as_list()
370            .ok()
371            .and_then(|rows| rows.first())
372            .and_then(|row| row.as_record().ok())
373            .and_then(|record| record.get("props"))
374            .and_then(|value| value.as_record().ok())
375            .expect("missing props record")
376            .clone();
377
378        assert_eq!(props.len(), 2);
379        assert_eq!(props.get("attr"), Some(&Value::int(1, span)));
380        assert_eq!(props.get("attr@2"), Some(&Value::int(2, span)));
381    }
382
383    #[test]
384    fn parse_errors_use_structured_kdl_diagnostics() {
385        let error =
386            parse_kdl_document("node 1.", KdlSpec::V2, Span::test_data()).expect_err("invalid KDL");
387
388        let ShellError::Generic(generic) = error else {
389            panic!("expected generic shell error");
390        };
391
392        let Some(ShellError::OutsideSpannedLabeledError { msg, .. }) = generic.inner.first() else {
393            panic!("expected structured inner parse diagnostic");
394        };
395
396        assert!(!msg.trim().is_empty());
397        assert_ne!(msg.trim(), "error parsing KDL text");
398    }
399
400    #[test]
401    fn multiple_kdl_diagnostics_are_aggregated() {
402        let err = KdlDocument::parse("node 1.").expect_err("input should fail to parse");
403        let mut diagnostics = err.diagnostics.clone();
404
405        diagnostics.push(
406            diagnostics
407                .first()
408                .expect("expected at least one diagnostic")
409                .clone(),
410        );
411
412        let message = kdl_diagnostics_message(&diagnostics);
413
414        assert!(message.contains("diagnostic 1:"));
415        assert!(message.contains("diagnostic 2:"));
416    }
417
418    #[test]
419    fn jik_object_round_trip_shape() {
420        let span = Span::test_data();
421        let doc = parse_kdl_document("- a=1 b=#true", KdlSpec::V2, span).expect("parse");
422        let value = jik_document_to_value(&doc, span, false).expect("jik");
423        let record = value.as_record().expect("record");
424        assert_eq!(record.get("a"), Some(&Value::int(1, span)));
425        assert_eq!(record.get("b"), Some(&Value::bool(true, span)));
426    }
427
428    #[test]
429    fn filesize_type_annotation_is_promoted() {
430        let span = Span::test_data();
431        let doc = parse_kdl_document("node (filesize)1024", KdlSpec::V2, span).expect("parse");
432        let output = nodes_document_to_value(&doc, span, false).expect("convert");
433        let args = output
434            .as_list()
435            .ok()
436            .and_then(|rows| rows.first())
437            .and_then(|row| row.as_record().ok())
438            .and_then(|record| record.get("args"))
439            .and_then(|value| value.as_list().ok())
440            .expect("args");
441        assert_eq!(args.first(), Some(&Value::filesize(1024, span)));
442    }
443
444    #[test]
445    fn v1_spec_parses_v1_booleans() {
446        let span = Span::test_data();
447        // KDL v1 uses bare `true`/`false`/`null` keywords (not #true).
448        let doc = parse_kdl_document("node flag=true", KdlSpec::V1, span).expect("v1 parse");
449        let output = nodes_document_to_value(&doc, span, false).expect("convert");
450        let props = output
451            .as_list()
452            .ok()
453            .and_then(|rows| rows.first())
454            .and_then(|row| row.as_record().ok())
455            .and_then(|record| record.get("props"))
456            .and_then(|value| value.as_record().ok())
457            .expect("props");
458        assert_eq!(props.get("flag"), Some(&Value::bool(true, span)));
459    }
460
461    #[test]
462    fn v2_rejects_v1_style_bare_true_property() {
463        assert!(
464            parse_kdl_document("node flag=true", KdlSpec::V2, Span::test_data()).is_err(),
465            "v2 should reject bare true property"
466        );
467    }
468
469    #[test]
470    fn v1_rejects_v2_style_hash_true_property() {
471        assert!(
472            parse_kdl_document("node flag=#true", KdlSpec::V1, Span::test_data()).is_err(),
473            "v1 should reject #true property"
474        );
475    }
476
477    #[test]
478    fn v1_and_v2_keyword_args_decode_identically() {
479        let span = Span::test_data();
480        let v1 = parse_kdl_document("node true false null", KdlSpec::V1, span).unwrap();
481        let v2 = parse_kdl_document("node #true #false #null", KdlSpec::V2, span).unwrap();
482        let rows_v1 = nodes_document_to_value(&v1, span, false).unwrap();
483        let rows_v2 = nodes_document_to_value(&v2, span, false).unwrap();
484        assert_eq!(rows_v1, rows_v2);
485    }
486
487    #[test]
488    fn v1_jik_parse_bare_keywords() {
489        let span = Span::test_data();
490        let doc = parse_kdl_document("- a=1 b=true c=null", KdlSpec::V1, span).unwrap();
491        let value = jik_document_to_value(&doc, span, false).unwrap();
492        assert_eq!(
493            value,
494            Value::test_record(record! {
495                "a" => Value::int(1, span),
496                "b" => Value::bool(true, span),
497                "c" => Value::nothing(span),
498            })
499        );
500    }
501
502    #[test]
503    fn v2_jik_parse_hash_keywords() {
504        let span = Span::test_data();
505        let doc = parse_kdl_document("- a=1 b=#true c=#null", KdlSpec::V2, span).unwrap();
506        let value = jik_document_to_value(&doc, span, false).unwrap();
507        assert_eq!(
508            value,
509            Value::test_record(record! {
510                "a" => Value::int(1, span),
511                "b" => Value::bool(true, span),
512                "c" => Value::nothing(span),
513            })
514        );
515    }
516
517    #[test]
518    fn v1_parse_error_is_structured() {
519        // #true is invalid in v1
520        let error =
521            parse_kdl_document("node #true", KdlSpec::V1, Span::test_data()).expect_err("v1 fail");
522        assert!(matches!(
523            error,
524            ShellError::Generic(_) | ShellError::CantConvert { .. }
525        ));
526    }
527
528    #[test]
529    fn kdl_error_source_is_bounded() {
530        let mut input = String::with_capacity(50_000);
531        for _ in 0..2000 {
532            input.push_str("node1 key=1; ");
533        }
534        input.push_str("node2 \"unclosed");
535
536        let result = parse_kdl_document(&input, KdlSpec::V2, Span::test_data());
537        assert!(result.is_err(), "should fail to parse");
538
539        let err = result.unwrap_err();
540        match &err {
541            ShellError::Generic(GenericError { inner, .. }) => {
542                let inner_err = inner.first().expect("should have inner error");
543                match inner_err {
544                    ShellError::OutsideSpannedLabeledError { src, .. } => {
545                        assert!(
546                            src.len() < 20_000,
547                            "error source should be bounded, got {} bytes",
548                            src.len()
549                        );
550                    }
551                    other => panic!("expected OutsideSpannedLabeledError, got {other:?}"),
552                }
553            }
554            other => panic!("expected Generic error, got {other:?}"),
555        }
556    }
557}