Skip to main content

nu_command/formats/from/
kdl.rs

1use crate::formats::{KDL_CANONICAL_METADATA_KEY, KDL_CANONICAL_METADATA_VALUE};
2use kdl::{KdlDocument, KdlError, KdlNode, KdlValue};
3use nu_engine::command_prelude::*;
4use nu_protocol::{
5    DEFAULT_ERROR_CONTEXT, shell_error::generic::GenericError, truncated_source_window,
6};
7use num_traits::ToPrimitive;
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 signature(&self) -> nu_protocol::Signature {
22        Signature::build("from kdl")
23            .input_output_types(vec![(Type::String, Type::Any)])
24            .category(Category::Formats)
25    }
26
27    fn examples(&self) -> Vec<Example<'_>> {
28        let span = Span::unknown();
29
30        vec![
31            Example {
32                example: r#""node attr=1 attr2=#true {bloc}" | from kdl"#,
33                description: "Converts KDL formatted string to canonical node rows.",
34                result: Some(Value::test_list(vec![Value::test_record(record! {
35                    "name" => Value::string("node", span),
36                    "args" => Value::test_list(vec![]),
37                    "props" => Value::test_record(record! {
38                        "attr" => 1.into_value(span),
39                        "attr2" => true.into_value(span),
40                    }),
41                    "children" => Value::test_list(vec![Value::test_record(record! {
42                        "name" => Value::string("bloc", span),
43                        "args" => Value::test_list(vec![]),
44                        "props" => Value::test_record(record! {}),
45                        "children" => Value::test_list(vec![]),
46                    })]),
47                })])),
48            },
49            Example {
50                description: "Converts KDL formatted string to canonical node rows.",
51                example: r#"'package { name nu; version 0.1; description "new type of shell" }' | from kdl"#,
52                result: Some(Value::test_list(vec![Value::test_record(record! {
53                    "name" => Value::string("package", span),
54                    "args" => Value::test_list(vec![]),
55                    "props" => Value::test_record(record! {}),
56                    "children" => Value::test_list(vec![
57                        Value::test_record(record! {
58                            "name" => Value::string("name", span),
59                            "args" => Value::test_list(vec![Value::string("nu", span)]),
60                            "props" => Value::test_record(record! {}),
61                            "children" => Value::test_list(vec![]),
62                        }),
63                        Value::test_record(record! {
64                            "name" => Value::string("version", span),
65                            "args" => Value::test_list(vec![Value::float(0.1, span)]),
66                            "props" => Value::test_record(record! {}),
67                            "children" => Value::test_list(vec![]),
68                        }),
69                        Value::test_record(record! {
70                            "name" => Value::string("description", span),
71                            "args" => Value::test_list(vec![Value::string("new type of shell", span)]),
72                            "props" => Value::test_record(record! {}),
73                            "children" => Value::test_list(vec![]),
74                        }),
75                    ]),
76                })])),
77            },
78            Example {
79                description: "Duplicate sibling node names are preserved in-order.",
80                example: r#""node one; node two" | from kdl"#,
81                result: Some(Value::test_list(vec![
82                    Value::test_record(record! {
83                        "name" => Value::string("node", span),
84                        "args" => Value::test_list(vec![Value::string("one", span)]),
85                        "props" => Value::test_record(record! {}),
86                        "children" => Value::test_list(vec![]),
87                    }),
88                    Value::test_record(record! {
89                        "name" => Value::string("node", span),
90                        "args" => Value::test_list(vec![Value::string("two", span)]),
91                        "props" => Value::test_record(record! {}),
92                        "children" => Value::test_list(vec![]),
93                    }),
94                ])),
95            },
96        ]
97    }
98
99    fn run(
100        &self,
101        _engine: &EngineState,
102        _stack: &mut Stack,
103        call: &Call,
104        mut input: PipelineData,
105    ) -> Result<PipelineData, ShellError> {
106        let span = input.span().unwrap_or(call.head);
107        let mut metadata = input
108            .take_metadata()
109            .unwrap_or_default()
110            .with_content_type(None);
111
112        let kdl_string_object = input.collect_string_strict(span)?;
113
114        let kdl_data = parse_kdl_document_with_diagnostics(&kdl_string_object.0, span)?;
115        let rows = convert_kdl_document_to_node_rows(kdl_data.nodes(), span)?;
116
117        // Mark this output as canonical KDL node rows so `to kdl` can round-trip
118        // without guessing by shape and accidentally reinterpreting normal records.
119        metadata.custom.insert(
120            KDL_CANONICAL_METADATA_KEY,
121            Value::string(KDL_CANONICAL_METADATA_VALUE, span),
122        );
123
124        Ok(Value::list(rows, span).into_pipeline_data_with_metadata(Some(metadata)))
125    }
126}
127
128fn parse_kdl_document_with_diagnostics(input: &str, span: Span) -> Result<KdlDocument, ShellError> {
129    KdlDocument::parse(input).map_err(|err| kdl_error_to_shell_error(input, span, &err))
130}
131
132fn kdl_error_to_shell_error(input: &str, span: Span, err: &KdlError) -> ShellError {
133    if let Some(diagnostic) = err.diagnostics.first() {
134        let diagnostic_message = kdl_diagnostics_message(&err.diagnostics);
135        let byte_offset = diagnostic.span.offset();
136        let (src, label_span) = truncated_source_window(
137            input,
138            Span::new(byte_offset, byte_offset),
139            DEFAULT_ERROR_CONTEXT,
140        );
141
142        return ShellError::Generic(
143            GenericError::new(
144                "Error while parsing KDL text",
145                "error parsing KDL text",
146                span,
147            )
148            .with_inner([ShellError::OutsideSpannedLabeledError {
149                src,
150                error: "Error while parsing KDL text".into(),
151                msg: diagnostic_message,
152                span: label_span,
153            }]),
154        );
155    }
156
157    ShellError::CantConvert {
158        to_type: format!("structured kdl data ({err})"),
159        from_type: "string".into(),
160        span,
161        help: None,
162    }
163}
164
165fn kdl_diagnostics_message(diagnostics: &[kdl::KdlDiagnostic]) -> String {
166    if diagnostics.len() == 1 {
167        return kdl_diagnostic_message(&diagnostics[0]);
168    }
169
170    diagnostics
171        .iter()
172        .enumerate()
173        .map(|(index, diagnostic)| {
174            format!(
175                "diagnostic {}:\n{}",
176                index + 1,
177                kdl_diagnostic_message(diagnostic)
178            )
179        })
180        .collect::<Vec<_>>()
181        .join("\n\n")
182}
183
184fn kdl_diagnostic_message(diagnostic: &kdl::KdlDiagnostic) -> String {
185    let mut parts = Vec::new();
186
187    if let Some(message) = &diagnostic.message {
188        parts.push(message.clone());
189    }
190
191    if let Some(label) = &diagnostic.label
192        && parts.last() != Some(label)
193    {
194        parts.push(label.clone());
195    }
196
197    if let Some(help) = &diagnostic.help {
198        parts.push(format!("help: {help}"));
199    }
200
201    if parts.is_empty() {
202        "error parsing KDL text".to_owned()
203    } else {
204        parts.join("\n")
205    }
206}
207
208fn convert_kdl_document_to_node_rows(
209    nodes: &[KdlNode],
210    span: Span,
211) -> Result<Vec<Value>, ShellError> {
212    let mut rows = Vec::with_capacity(nodes.len());
213
214    for node in nodes {
215        rows.push(convert_kdl_node_to_node_row(node, span)?);
216    }
217
218    Ok(rows)
219}
220
221fn convert_kdl_node_to_node_row(kdl_node: &KdlNode, span: Span) -> Result<Value, ShellError> {
222    let mut args = Vec::new();
223    let mut props = Record::new();
224
225    for entry in kdl_node.entries() {
226        if let Some(name) = entry.name() {
227            props.insert(
228                name.value().to_string(),
229                convert_kdl_value_to_nu_value(entry.value(), span)?,
230            );
231            continue;
232        }
233
234        args.push(convert_kdl_value_to_nu_value(entry.value(), span)?);
235    }
236
237    let children = if let Some(children_doc) = kdl_node.children() {
238        convert_kdl_document_to_node_rows(children_doc.nodes(), span)?
239    } else {
240        Vec::new()
241    };
242
243    let row = record! {
244        "name" => Value::string(kdl_node.name().value(), span),
245        "args" => Value::list(args, span),
246        "props" => props.into_value(span),
247        "children" => Value::list(children, span),
248    };
249
250    Ok(row.into_value(span))
251}
252
253fn convert_kdl_value_to_nu_value(value: &KdlValue, span: Span) -> Result<Value, ShellError> {
254    match value {
255        KdlValue::String(val) => Ok(Value::string(val, span)),
256        KdlValue::Integer(val) => Ok(Value::int(
257            val.to_i64().ok_or(ShellError::UnsupportedInput {
258                msg: "integer value is too large to fit in i64".to_owned(),
259                input: "value originates from here".to_owned(),
260                msg_span: span,
261                input_span: span,
262            })?,
263            span,
264        )),
265        KdlValue::Float(val) => Ok(Value::float(*val, span)),
266        KdlValue::Bool(val) => Ok(Value::bool(*val, span)),
267        KdlValue::Null => Ok(Value::nothing(span)),
268    }
269}
270
271#[cfg(test)]
272mod test {
273    use super::*;
274
275    fn node_name(row: &Value) -> &str {
276        row.as_record()
277            .ok()
278            .and_then(|record| record.get("name"))
279            .and_then(|value| value.as_str().ok())
280            .expect("row should contain string name")
281    }
282
283    #[test]
284    fn test_examples() -> nu_test_support::Result {
285        nu_test_support::test().examples(FromKdl)
286    }
287
288    #[test]
289    fn duplicate_sibling_names_are_preserved_in_order() {
290        let span = Span::test_data();
291        let kdl_document = KdlDocument::parse("node one\nnode two\nnode three")
292            .expect("failed to parse duplicate sibling document");
293
294        let output_rows = convert_kdl_document_to_node_rows(kdl_document.nodes(), span)
295            .expect("conversion failed");
296
297        assert_eq!(output_rows.len(), 3);
298        assert_eq!(node_name(&output_rows[0]), "node");
299        assert_eq!(node_name(&output_rows[1]), "node");
300        assert_eq!(node_name(&output_rows[2]), "node");
301
302        let second_args = output_rows[1]
303            .as_record()
304            .ok()
305            .and_then(|record| record.get("args"))
306            .and_then(|value| value.as_list().ok())
307            .expect("missing args list");
308
309        assert_eq!(
310            second_args.first().cloned(),
311            Some(Value::string("two", span))
312        );
313    }
314
315    #[test]
316    fn duplicate_properties_use_rightmost_value() {
317        let span = Span::test_data();
318        let kdl_document = KdlDocument::parse("node attr=1 attr=2")
319            .expect("failed to parse duplicate property document");
320
321        let output_rows = convert_kdl_document_to_node_rows(kdl_document.nodes(), span)
322            .expect("conversion failed");
323
324        let props = output_rows[0]
325            .as_record()
326            .ok()
327            .and_then(|record| record.get("props"))
328            .and_then(|value| value.as_record().ok())
329            .expect("missing props record");
330
331        assert_eq!(props.len(), 1);
332        assert_eq!(props.get("attr"), Some(&Value::int(2, span)));
333    }
334
335    #[test]
336    fn parse_errors_use_structured_kdl_diagnostics() {
337        let error = parse_kdl_document_with_diagnostics("node 1.", Span::test_data())
338            .expect_err("invalid KDL should fail");
339
340        let ShellError::Generic(generic) = error else {
341            panic!("expected generic shell error");
342        };
343
344        let Some(ShellError::OutsideSpannedLabeledError { msg, .. }) = generic.inner.first() else {
345            panic!("expected structured inner parse diagnostic");
346        };
347
348        assert!(!msg.trim().is_empty());
349        assert_ne!(msg.trim(), "error parsing KDL text");
350    }
351
352    #[test]
353    fn multiple_kdl_diagnostics_are_aggregated() {
354        let err = KdlDocument::parse("node 1.").expect_err("input should fail to parse");
355        let mut diagnostics = err.diagnostics.clone();
356
357        diagnostics.push(
358            diagnostics
359                .first()
360                .expect("expected at least one diagnostic")
361                .clone(),
362        );
363
364        let message = kdl_diagnostics_message(&diagnostics);
365
366        assert!(message.contains("diagnostic 1:"));
367        assert!(message.contains("diagnostic 2:"));
368    }
369
370    #[test]
371    fn canonical_row_shape_splits_args_props_and_children() {
372        let span = Span::test_data();
373        let kdl_document = KdlDocument::parse("item 1 2 enabled=#true { child 9 }")
374            .expect("failed to parse mixed kdl node");
375
376        let output_rows = convert_kdl_document_to_node_rows(kdl_document.nodes(), span)
377            .expect("conversion failed");
378
379        let row = output_rows
380            .first()
381            .and_then(|value| value.as_record().ok())
382            .expect("missing top-level row");
383
384        assert_eq!(row.get("name").cloned(), Some(Value::string("item", span)));
385        assert_eq!(
386            row.get("args")
387                .and_then(|value| value.as_list().ok())
388                .map(|args| args.len()),
389            Some(2)
390        );
391        assert_eq!(
392            row.get("props")
393                .and_then(|value| value.as_record().ok())
394                .and_then(|props| props.get("enabled"))
395                .cloned(),
396            Some(Value::bool(true, span))
397        );
398        assert_eq!(
399            row.get("children")
400                .and_then(|value| value.as_list().ok())
401                .map(|children| children.len()),
402            Some(1)
403        );
404    }
405
406    #[test]
407    fn test_official_kdl_website_example() {
408        const KDL_WEBSITE_EXAMPLE: &str = r#"
409        package {
410          name my-pkg
411          version "1.2.3"
412
413          dependencies {
414            // Nodes can have standalone values as well as
415            // key/value pairs.
416            lodash "^3.2.1" optional=#true alias=underscore
417          }
418
419          scripts {
420            // "Raw" and dedented multi-line strings are supported.
421            message """
422            hello
423            world
424            """
425          }
426
427          // `\` breaks up a single node across multiple lines.
428          the-matrix 1 2 3 \
429          4 5 6 \
430          7 8 9
431
432          // "Slashdash" comments operate at the node level,
433          // with just `/-`.
434          /-this-is-commented {
435            this entire node {
436              is gone
437            }
438          }
439        }"#;
440
441        let kdl_document =
442            KdlDocument::parse(KDL_WEBSITE_EXAMPLE).expect("failed to parse kdl string");
443
444        let span = Span::test_data();
445
446        let output_rows = convert_kdl_document_to_node_rows(kdl_document.nodes(), span)
447            .expect("kdl conversion failed");
448
449        let package = output_rows
450            .first()
451            .and_then(|row| row.as_record().ok())
452            .expect("missing package node");
453
454        let package_children = package
455            .get("children")
456            .and_then(|value| value.as_list().ok())
457            .expect("missing package children");
458
459        let matrix_row = package_children
460            .iter()
461            .find(|node| {
462                node.as_record()
463                    .ok()
464                    .and_then(|record| record.get("name"))
465                    .and_then(|name| name.as_str().ok())
466                    == Some("the-matrix")
467            })
468            .expect("missing matrix row");
469
470        assert_eq!(
471            matrix_row
472                .as_record()
473                .ok()
474                .and_then(|record| record.get("args"))
475                .and_then(|args| args.as_list().ok())
476                .and_then(|args| args.first())
477                .cloned()
478                .expect("missing matrix first arg"),
479            Value::int(1, span)
480        );
481
482        let scripts_row = package_children
483            .iter()
484            .find(|node| {
485                node.as_record()
486                    .ok()
487                    .and_then(|record| record.get("name"))
488                    .and_then(|name| name.as_str().ok())
489                    == Some("scripts")
490            })
491            .expect("missing scripts row");
492
493        let message_row = scripts_row
494            .as_record()
495            .ok()
496            .and_then(|record| record.get("children"))
497            .and_then(|children| children.as_list().ok())
498            .and_then(|children| {
499                children.iter().find(|node| {
500                    node.as_record()
501                        .ok()
502                        .and_then(|record| record.get("name"))
503                        .and_then(|name| name.as_str().ok())
504                        == Some("message")
505                })
506            })
507            .expect("missing message row");
508
509        assert_eq!(
510            message_row
511                .as_record()
512                .ok()
513                .and_then(|record| record.get("args"))
514                .and_then(|args| args.as_list().ok())
515                .and_then(|args| args.first())
516                .cloned()
517                .expect("missing message text"),
518            Value::string("hello\nworld", span)
519        );
520    }
521
522    #[test]
523    fn kdl_error_source_is_bounded() {
524        let mut input = String::with_capacity(50_000);
525        for _ in 0..2000 {
526            input.push_str("node1 key=1; ");
527        }
528        input.push_str("node2 \"unclosed"); // Syntax error: unclosed string
529
530        let result = parse_kdl_document_with_diagnostics(&input, Span::test_data());
531        assert!(result.is_err(), "should fail to parse");
532
533        let err = result.unwrap_err();
534        match &err {
535            ShellError::Generic(GenericError { inner, .. }) => {
536                let inner_err = inner.first().expect("should have inner error");
537                match inner_err {
538                    ShellError::OutsideSpannedLabeledError { src, .. } => {
539                        assert!(
540                            src.len() < 20_000,
541                            "error source should be bounded, got {} bytes",
542                            src.len()
543                        );
544                    }
545                    other => panic!("expected OutsideSpannedLabeledError, got {other:?}"),
546                }
547            }
548            other => panic!("expected Generic error, got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn kdl_parse_success_not_affected() {
554        let result = parse_kdl_document_with_diagnostics(
555            r#"node1 key=1; node2 key="val""#,
556            Span::test_data(),
557        );
558        assert!(result.is_ok(), "valid KDL should still parse");
559    }
560}