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 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"); 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}