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 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 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}