1use indexmap::IndexMap;
2use nu_cmd_base::formats::to::delimited::merge_descriptors;
3use nu_engine::command_prelude::*;
4use nu_protocol::{Config, ast::PathMember};
5use std::collections::HashSet;
6
7#[derive(Clone)]
8pub struct ToMd;
9
10#[derive(Clone, Copy, Default, PartialEq)]
12enum ListStyle {
13 None,
15 Ordered,
17 #[default]
19 Unordered,
20}
21
22impl ListStyle {
23 const OPTIONS: &[&'static str] = &["ordered", "unordered", "none"];
24
25 fn from_str(s: &str) -> Option<Self> {
26 match s.to_ascii_lowercase().as_str() {
27 "ordered" => Some(Self::Ordered),
28 "unordered" => Some(Self::Unordered),
29 "none" => Some(Self::None),
30 _ => None,
31 }
32 }
33}
34
35struct ToMdOptions {
36 pretty: bool,
37 per_element: bool,
38 center: Option<Vec<CellPath>>,
39 escape_md: bool,
40 escape_html: bool,
41 list_style: ListStyle,
42}
43
44const SPECIAL_MARKDOWN_HEADERS: &[&str] = &["h1", "h2", "h3", "blockquote"];
46
47fn is_special_markdown_record(record: &nu_protocol::Record) -> bool {
49 record.len() == 1
50 && record.get_index(0).is_some_and(|(header, _)| {
51 SPECIAL_MARKDOWN_HEADERS.contains(&header.to_ascii_lowercase().as_str())
52 })
53}
54
55impl Command for ToMd {
56 fn name(&self) -> &str {
57 "to md"
58 }
59
60 fn signature(&self) -> Signature {
61 Signature::build("to md")
62 .input_output_types(vec![(Type::Any, Type::String)])
63 .switch(
64 "pretty",
65 "Formats the Markdown table to vertically align items.",
66 Some('p'),
67 )
68 .switch(
69 "per-element",
70 "Treat each row as markdown syntax element.",
71 Some('e'),
72 )
73 .named(
74 "center",
75 SyntaxShape::List(Box::new(SyntaxShape::CellPath)),
76 "Formats the Markdown table to center given columns.",
77 Some('c'),
78 )
79 .switch(
80 "escape-md",
81 "Escapes Markdown special characters.",
82 Some('m'),
83 )
84 .switch("escape-html", "Escapes HTML special characters.", Some('t'))
85 .switch(
86 "escape-all",
87 "Escapes both Markdown and HTML special characters.",
88 Some('a'),
89 )
90 .named(
91 "list",
92 SyntaxShape::String,
93 "Format lists as 'ordered' (1. 2. 3.), 'unordered' (* * *), or 'none'. Default: unordered.",
94 Some('l'),
95 )
96 .category(Category::Formats)
97 }
98
99 fn description(&self) -> &str {
100 "Convert table into simple Markdown."
101 }
102
103 fn examples(&self) -> Vec<Example<'_>> {
104 vec![
105 Example {
106 description: "Outputs an MD string representing the contents of this table.",
107 example: "[[foo bar]; [1 2]] | to md",
108 result: Some(Value::test_string(
109 "| foo | bar |\n| --- | --- |\n| 1 | 2 |",
110 )),
111 },
112 Example {
113 description: "Optionally, output a formatted markdown string.",
114 example: "[[foo bar]; [1 2]] | to md --pretty",
115 result: Some(Value::test_string(
116 "| foo | bar |\n| --- | --- |\n| 1 | 2 |",
117 )),
118 },
119 Example {
120 description: "Treat each row as a markdown element.",
121 example: r#"[{"H1": "Welcome to Nushell" } [[foo bar]; [1 2]]] | to md --per-element --pretty"#,
122 result: Some(Value::test_string(
123 "# Welcome to Nushell\n| foo | bar |\n| --- | --- |\n| 1 | 2 |",
124 )),
125 },
126 Example {
127 description: "Render a list (unordered by default).",
128 example: "[0 1 2] | to md",
129 result: Some(Value::test_string("* 0\n* 1\n* 2")),
130 },
131 Example {
132 description: "Separate list into markdown tables.",
133 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4} {foo: 5}] | to md --per-element",
134 result: Some(Value::test_string(
135 "| foo | bar |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n\n| foo |\n| --- |\n| 5 |",
136 )),
137 },
138 Example {
139 description: "Center a column of a markdown table.",
140 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4}] | to md --pretty --center [bar]",
141 result: Some(Value::test_string(
142 "| foo | bar |\n| --- |:---:|\n| 1 | 2 |\n| 3 | 4 |",
143 )),
144 },
145 Example {
146 description: "Escape markdown special characters.",
147 example: r#"[ {foo: "_1_", bar: "\# 2"} {foo: "[3]", bar: "4|5"}] | to md --escape-md"#,
148 result: Some(Value::test_string(
149 "| foo | bar |\n| --- | --- |\n| \\_1\\_ | \\# 2 |\n| \\[3\\] | 4\\|5 |",
150 )),
151 },
152 Example {
153 description: "Escape html special characters.",
154 example: r#"[ {a: p, b: "<p>Welcome to nushell</p>"}] | to md --escape-html"#,
155 result: Some(Value::test_string(
156 "| a | b |\n| --- | --- |\n| p | <p>Welcome to nushell</p> |",
157 )),
158 },
159 Example {
160 description: "Render a list as an ordered markdown list.",
161 example: "[one two three] | to md --list ordered",
162 result: Some(Value::test_string("1. one\n2. two\n3. three")),
163 },
164 Example {
165 description: "Render a list without markers.",
166 example: "[one two three] | to md --list none",
167 result: Some(Value::test_string("one\ntwo\nthree")),
168 },
169 ]
170 }
171
172 fn run(
173 &self,
174 engine_state: &EngineState,
175 stack: &mut Stack,
176 call: &Call,
177 input: PipelineData,
178 ) -> Result<PipelineData, ShellError> {
179 let head = call.head;
180
181 let pretty = call.has_flag(engine_state, stack, "pretty")?;
182 let per_element = call.has_flag(engine_state, stack, "per-element")?;
183 let escape_md = call.has_flag(engine_state, stack, "escape-md")?;
184 let escape_html = call.has_flag(engine_state, stack, "escape-html")?;
185 let escape_both = call.has_flag(engine_state, stack, "escape-all")?;
186 let center: Option<Vec<CellPath>> = call.get_flag(engine_state, stack, "center")?;
187 let list_style_str: Option<Spanned<String>> = call.get_flag(engine_state, stack, "list")?;
188
189 let list_style = match &list_style_str {
190 Some(spanned) => {
191 ListStyle::from_str(&spanned.item).ok_or_else(|| ShellError::InvalidValue {
192 valid: format!("one of {}", ListStyle::OPTIONS.join(", ")),
193 actual: spanned.item.clone(),
194 span: spanned.span,
195 })?
196 }
197 None => ListStyle::default(),
198 };
199
200 let config = stack.get_config(engine_state);
201
202 to_md(
203 input,
204 ToMdOptions {
205 pretty,
206 per_element,
207 center,
208 escape_md: escape_md || escape_both,
209 escape_html: escape_html || escape_both,
210 list_style,
211 },
212 &config,
213 head,
214 )
215 }
216}
217
218fn to_md(
219 input: PipelineData,
220 options: ToMdOptions,
221 config: &Config,
222 head: Span,
223) -> Result<PipelineData, ShellError> {
224 let metadata = input
226 .metadata()
227 .unwrap_or_default()
228 .with_content_type(Some("text/markdown".into()));
229
230 let values: Vec<Value> = input.into_iter().collect();
232
233 let is_simple_list = !values
236 .iter()
237 .any(|v| matches!(v, Value::Record { .. } | Value::List { .. }));
238
239 if is_simple_list {
241 let result = values
242 .into_iter()
243 .enumerate()
244 .map(|(idx, val)| {
245 format_list_item(
246 val,
247 idx,
248 options.list_style,
249 options.escape_md,
250 options.escape_html,
251 config,
252 )
253 })
254 .collect::<Vec<String>>()
255 .join("")
256 .trim()
257 .to_string();
258 return Ok(Value::string(result, head).into_pipeline_data_with_metadata(Some(metadata)));
259 }
260
261 let input = Value::list(values, head).into_pipeline_data();
263 let (grouped_input, single_list) = group_by(input, head, config);
264 if options.per_element || single_list {
265 return Ok(Value::string(
266 grouped_input
267 .into_iter()
268 .scan(0usize, |list_idx, val| {
269 Some(match &val {
270 Value::List { .. } => {
271 format!(
272 "{}\n\n",
273 table(
274 val.into_pipeline_data(),
275 options.pretty,
276 &options.center,
277 options.escape_md,
278 options.escape_html,
279 config
280 )
281 )
282 }
283 Value::Record { val: record, .. } => {
285 if is_special_markdown_record(record) {
286 fragment(
288 val,
289 options.pretty,
290 &options.center,
291 options.escape_md,
292 options.escape_html,
293 config,
294 )
295 } else {
296 format!(
298 "{}\n\n",
299 fragment(
300 val,
301 options.pretty,
302 &options.center,
303 options.escape_md,
304 options.escape_html,
305 config
306 )
307 )
308 }
309 }
310 _ => {
311 let result = format_list_item(
312 val,
313 *list_idx,
314 options.list_style,
315 options.escape_md,
316 options.escape_html,
317 config,
318 );
319 *list_idx += 1;
320 result
321 }
322 })
323 })
324 .collect::<Vec<String>>()
325 .join("")
326 .trim(),
327 head,
328 )
329 .into_pipeline_data_with_metadata(Some(metadata)));
330 }
331 Ok(Value::string(
332 table(
333 grouped_input,
334 options.pretty,
335 &options.center,
336 options.escape_md,
337 options.escape_html,
338 config,
339 ),
340 head,
341 )
342 .into_pipeline_data_with_metadata(Some(metadata)))
343}
344
345fn format_list_item(
347 input: Value,
348 index: usize,
349 list_style: ListStyle,
350 escape_md: bool,
351 escape_html: bool,
352 config: &Config,
353) -> String {
354 let value_string = input.to_expanded_string("|", config);
355 let escaped = escape_value(value_string, escape_md, escape_html, false);
356
357 match list_style {
358 ListStyle::Ordered => format!("{}. {}\n", index + 1, escaped),
359 ListStyle::Unordered => format!("* {}\n", escaped),
360 ListStyle::None => format!("{}\n", escaped),
361 }
362}
363
364fn escape_markdown_characters(input: String, escape_md: bool, for_table: bool) -> String {
365 let mut output = String::with_capacity(input.len());
366 for ch in input.chars() {
367 let must_escape = match ch {
368 '\\' => true,
369 '|' if for_table => true,
370 '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '<' | '>' | '#' | '+' | '-'
371 | '.' | '!'
372 if escape_md =>
373 {
374 true
375 }
376 _ => false,
377 };
378
379 if must_escape {
380 output.push('\\');
381 }
382 output.push(ch);
383 }
384 output
385}
386
387fn escape_value(value: String, escape_md: bool, escape_html: bool, for_table: bool) -> String {
389 escape_markdown_characters(
390 if escape_html {
391 v_htmlescape::escape(&value).to_string()
392 } else {
393 value
394 },
395 escape_md,
396 for_table,
397 )
398}
399
400fn fragment(
401 input: Value,
402 pretty: bool,
403 center: &Option<Vec<CellPath>>,
404 escape_md: bool,
405 escape_html: bool,
406 config: &Config,
407) -> String {
408 let mut out = String::new();
409
410 if let Value::Record { val, .. } = &input {
411 match val.get_index(0) {
412 Some((header, data)) if is_special_markdown_record(val) => {
413 let markup = match header.to_ascii_lowercase().as_ref() {
415 "h1" => "# ",
416 "h2" => "## ",
417 "h3" => "### ",
418 "blockquote" => "> ",
419 _ => "> ", };
421
422 let value_string = data.to_expanded_string("|", config);
423 out.push_str(markup);
424 out.push_str(&escape_value(value_string, escape_md, escape_html, false));
425 }
426 _ => {
427 out = table(
428 input.into_pipeline_data(),
429 pretty,
430 center,
431 escape_md,
432 escape_html,
433 config,
434 )
435 }
436 }
437 } else {
438 let value_string = input.to_expanded_string("|", config);
439 out = escape_value(value_string, escape_md, escape_html, false);
440 }
441
442 out.push('\n');
443 out
444}
445
446fn collect_headers(headers: &[String], escape_md: bool) -> (Vec<String>, Vec<usize>) {
447 let mut escaped_headers: Vec<String> = Vec::new();
448 let mut column_widths: Vec<usize> = Vec::new();
449
450 if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
451 for header in headers {
452 let escaped_header_string = escape_markdown_characters(
453 v_htmlescape::escape(header).to_string(),
454 escape_md,
455 true,
456 );
457 column_widths.push(escaped_header_string.len());
458 escaped_headers.push(escaped_header_string);
459 }
460 } else {
461 column_widths = vec![0; headers.len()];
462 }
463
464 (escaped_headers, column_widths)
465}
466
467fn table(
468 input: PipelineData,
469 pretty: bool,
470 center: &Option<Vec<CellPath>>,
471 escape_md: bool,
472 escape_html: bool,
473 config: &Config,
474) -> String {
475 let vec_of_values = input
476 .into_iter()
477 .flat_map(|val| match val {
478 Value::List { vals, .. } => vals,
479 other => vec![other],
480 })
481 .collect::<Vec<Value>>();
482 let mut headers = merge_descriptors(&vec_of_values);
483
484 let mut empty_header_index = 0;
485 for value in &vec_of_values {
486 if let Value::Record { val, .. } = value {
487 for column in val.columns() {
488 if column.is_empty() && !headers.contains(&String::new()) {
489 headers.insert(empty_header_index, String::new());
490 empty_header_index += 1;
491 break;
492 }
493 empty_header_index += 1;
494 }
495 }
496 }
497
498 let (escaped_headers, mut column_widths) = collect_headers(&headers, escape_md);
499
500 let mut escaped_rows: Vec<Vec<String>> = Vec::new();
501
502 for row in vec_of_values {
503 let mut escaped_row: Vec<String> = Vec::new();
504 let span = row.span();
505
506 match row.to_owned() {
507 Value::Record { val: row, .. } => {
508 for i in 0..headers.len() {
509 let value_string = row
510 .get(&headers[i])
511 .cloned()
512 .unwrap_or_else(|| Value::nothing(span))
513 .to_expanded_string(", ", config);
514 let escaped_string = escape_markdown_characters(
515 if escape_html {
516 v_htmlescape::escape(&value_string).to_string()
517 } else {
518 value_string
519 },
520 escape_md,
521 true,
522 );
523
524 let new_column_width = escaped_string.len();
525 escaped_row.push(escaped_string);
526
527 if column_widths[i] < new_column_width {
528 column_widths[i] = new_column_width;
529 }
530 if column_widths[i] < 3 {
531 column_widths[i] = 3;
532 }
533 }
534 }
535 p => {
536 let value_string =
537 v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
538 escaped_row.push(value_string);
539 }
540 }
541
542 escaped_rows.push(escaped_row);
543 }
544
545 if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
546 && escaped_rows.is_empty()
547 {
548 String::from("")
549 } else {
550 get_output_string(
551 &escaped_headers,
552 &escaped_rows,
553 &column_widths,
554 pretty,
555 center,
556 )
557 .trim()
558 .to_string()
559 }
560}
561
562pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
563 let mut lists = IndexMap::new();
564 let mut single_list = false;
565 for val in values {
566 if let Value::Record {
567 val: ref record, ..
568 } = val
569 {
570 lists
571 .entry(record.columns().map(|c| c.as_str()).collect::<String>())
572 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
573 .or_insert_with(|| vec![val.clone()]);
574 } else {
575 lists
576 .entry(val.to_expanded_string(",", config))
577 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
578 .or_insert_with(|| vec![val.clone()]);
579 }
580 }
581 let mut output = vec![];
582 for (_, mut value) in lists {
583 if value.len() == 1 {
584 output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
585 } else {
586 output.push(Value::list(value.to_vec(), head))
587 }
588 }
589 if output.len() == 1 {
590 single_list = true;
591 }
592 (Value::list(output, head).into_pipeline_data(), single_list)
593}
594
595fn get_output_string(
596 headers: &[String],
597 rows: &[Vec<String>],
598 column_widths: &[usize],
599 pretty: bool,
600 center: &Option<Vec<CellPath>>,
601) -> String {
602 let mut output_string = String::new();
603
604 let mut to_center: HashSet<String> = HashSet::new();
605 if let Some(center_vec) = center.as_ref() {
606 for cell_path in center_vec {
607 if let Some(PathMember::String { val, .. }) = cell_path
608 .members
609 .iter()
610 .find(|member| matches!(member, PathMember::String { .. }))
611 {
612 to_center.insert(val.clone());
613 }
614 }
615 }
616
617 if !headers.is_empty() {
618 output_string.push('|');
619
620 for i in 0..headers.len() {
621 output_string.push(' ');
622 if pretty {
623 if center.is_some() && to_center.contains(&headers[i]) {
624 output_string.push_str(&get_centered_string(
625 headers[i].clone(),
626 column_widths[i],
627 ' ',
628 ));
629 } else {
630 output_string.push_str(&get_padded_string(
631 headers[i].clone(),
632 column_widths[i],
633 ' ',
634 ));
635 }
636 } else {
637 output_string.push_str(&headers[i]);
638 }
639
640 output_string.push_str(" |");
641 }
642
643 output_string.push_str("\n|");
644
645 for i in 0..headers.len() {
646 let centered_column = center.is_some() && to_center.contains(&headers[i]);
647 let border_char = if centered_column { ':' } else { ' ' };
648 if pretty {
649 output_string.push(border_char);
650 output_string.push_str(&get_padded_string(
651 String::from("-"),
652 column_widths[i],
653 '-',
654 ));
655 output_string.push(border_char);
656 } else if centered_column {
657 output_string.push_str(":---:");
658 } else {
659 output_string.push_str(" --- ");
660 }
661
662 output_string.push('|');
663 }
664
665 output_string.push('\n');
666 }
667
668 for row in rows {
669 if !headers.is_empty() {
670 output_string.push('|');
671 }
672
673 for i in 0..row.len() {
674 if !headers.is_empty() {
675 output_string.push(' ');
676 }
677
678 if pretty && column_widths.get(i).is_some() {
679 if center.is_some() && to_center.contains(&headers[i]) {
680 output_string.push_str(&get_centered_string(
681 row[i].clone(),
682 column_widths[i],
683 ' ',
684 ));
685 } else {
686 output_string.push_str(&get_padded_string(
687 row[i].clone(),
688 column_widths[i],
689 ' ',
690 ));
691 }
692 } else {
693 output_string.push_str(&row[i]);
694 }
695
696 if !headers.is_empty() {
697 output_string.push_str(" |");
698 }
699 }
700
701 output_string.push('\n');
702 }
703
704 output_string
705}
706
707fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
708 let total_padding = if text.len() > desired_length {
709 0
710 } else {
711 desired_length - text.len()
712 };
713
714 let repeat_left = total_padding / 2;
715 let repeat_right = total_padding - repeat_left;
716
717 format!(
718 "{}{}{}",
719 padding_character.to_string().repeat(repeat_left),
720 text,
721 padding_character.to_string().repeat(repeat_right)
722 )
723}
724
725fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
726 let repeat_length = if text.len() > desired_length {
727 0
728 } else {
729 desired_length - text.len()
730 };
731
732 format!(
733 "{}{}",
734 text,
735 padding_character.to_string().repeat(repeat_length)
736 )
737}
738
739#[cfg(test)]
740mod tests {
741 use crate::{Get, Metadata};
742
743 use super::*;
744 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
745 use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
746
747 fn one(string: &str) -> String {
748 string
749 .lines()
750 .skip(1)
751 .map(|line| line.trim())
752 .collect::<Vec<&str>>()
753 .join("\n")
754 .trim_end()
755 .to_string()
756 }
757
758 #[test]
759 fn test_examples() -> nu_test_support::Result {
760 nu_test_support::test().examples(ToMd)
761 }
762
763 #[test]
764 fn render_h1() {
765 let value = Value::test_record(record! {
766 "H1" => Value::test_string("Ecuador"),
767 });
768
769 assert_eq!(
770 fragment(value, false, &None, false, false, &Config::default()),
771 "# Ecuador\n"
772 );
773 }
774
775 #[test]
776 fn render_h2() {
777 let value = Value::test_record(record! {
778 "H2" => Value::test_string("Ecuador"),
779 });
780
781 assert_eq!(
782 fragment(value, false, &None, false, false, &Config::default()),
783 "## Ecuador\n"
784 );
785 }
786
787 #[test]
788 fn render_h3() {
789 let value = Value::test_record(record! {
790 "H3" => Value::test_string("Ecuador"),
791 });
792
793 assert_eq!(
794 fragment(value, false, &None, false, false, &Config::default()),
795 "### Ecuador\n"
796 );
797 }
798
799 #[test]
800 fn render_blockquote() {
801 let value = Value::test_record(record! {
802 "BLOCKQUOTE" => Value::test_string("Ecuador"),
803 });
804
805 assert_eq!(
806 fragment(value, false, &None, false, false, &Config::default()),
807 "> Ecuador\n"
808 );
809 }
810
811 #[test]
812 fn render_table() {
813 let value = Value::test_list(vec![
814 Value::test_record(record! {
815 "country" => Value::test_string("Ecuador"),
816 }),
817 Value::test_record(record! {
818 "country" => Value::test_string("New Zealand"),
819 }),
820 Value::test_record(record! {
821 "country" => Value::test_string("USA"),
822 }),
823 ]);
824
825 assert_eq!(
826 table(
827 value.clone().into_pipeline_data(),
828 false,
829 &None,
830 false,
831 false,
832 &Config::default()
833 ),
834 one("
835 | country |
836 | --- |
837 | Ecuador |
838 | New Zealand |
839 | USA |
840 ")
841 );
842
843 assert_eq!(
844 table(
845 value.into_pipeline_data(),
846 true,
847 &None,
848 false,
849 false,
850 &Config::default()
851 ),
852 one("
853 | country |
854 | ----------- |
855 | Ecuador |
856 | New Zealand |
857 | USA |
858 ")
859 );
860 }
861
862 #[test]
863 fn test_empty_column_header() {
864 let value = Value::test_list(vec![
865 Value::test_record(record! {
866 "" => Value::test_string("1"),
867 "foo" => Value::test_string("2"),
868 }),
869 Value::test_record(record! {
870 "" => Value::test_string("3"),
871 "foo" => Value::test_string("4"),
872 }),
873 ]);
874
875 assert_eq!(
876 table(
877 value.clone().into_pipeline_data(),
878 false,
879 &None,
880 false,
881 false,
882 &Config::default()
883 ),
884 one("
885 | | foo |
886 | --- | --- |
887 | 1 | 2 |
888 | 3 | 4 |
889 ")
890 );
891 }
892
893 #[test]
894 fn test_empty_row_value() {
895 let value = Value::test_list(vec![
896 Value::test_record(record! {
897 "foo" => Value::test_string("1"),
898 "bar" => Value::test_string("2"),
899 }),
900 Value::test_record(record! {
901 "foo" => Value::test_string("3"),
902 "bar" => Value::test_string("4"),
903 }),
904 Value::test_record(record! {
905 "foo" => Value::test_string("5"),
906 "bar" => Value::test_string(""),
907 }),
908 ]);
909
910 assert_eq!(
911 table(
912 value.clone().into_pipeline_data(),
913 false,
914 &None,
915 false,
916 false,
917 &Config::default()
918 ),
919 one("
920 | foo | bar |
921 | --- | --- |
922 | 1 | 2 |
923 | 3 | 4 |
924 | 5 | |
925 ")
926 );
927 }
928
929 #[test]
930 fn test_center_column() {
931 let value = Value::test_list(vec![
932 Value::test_record(record! {
933 "foo" => Value::test_string("1"),
934 "bar" => Value::test_string("2"),
935 }),
936 Value::test_record(record! {
937 "foo" => Value::test_string("3"),
938 "bar" => Value::test_string("4"),
939 }),
940 Value::test_record(record! {
941 "foo" => Value::test_string("5"),
942 "bar" => Value::test_string("6"),
943 }),
944 ]);
945
946 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
947 members: vec![PathMember::test_string(
948 "bar".into(),
949 false,
950 Casing::Sensitive,
951 )],
952 })]);
953
954 let cell_path: Vec<CellPath> = center_columns
955 .into_list()
956 .unwrap()
957 .into_iter()
958 .map(|v| v.into_cell_path().unwrap())
959 .collect();
960
961 let center: Option<Vec<CellPath>> = Some(cell_path);
962
963 assert_eq!(
965 table(
966 value.clone().into_pipeline_data(),
967 true,
968 ¢er,
969 false,
970 false,
971 &Config::default()
972 ),
973 one("
974 | foo | bar |
975 | --- |:---:|
976 | 1 | 2 |
977 | 3 | 4 |
978 | 5 | 6 |
979 ")
980 );
981
982 assert_eq!(
984 table(
985 value.clone().into_pipeline_data(),
986 false,
987 ¢er,
988 false,
989 false,
990 &Config::default()
991 ),
992 one("
993 | foo | bar |
994 | --- |:---:|
995 | 1 | 2 |
996 | 3 | 4 |
997 | 5 | 6 |
998 ")
999 );
1000 }
1001
1002 #[test]
1003 fn test_empty_center_column() {
1004 let value = Value::test_list(vec![
1005 Value::test_record(record! {
1006 "foo" => Value::test_string("1"),
1007 "bar" => Value::test_string("2"),
1008 }),
1009 Value::test_record(record! {
1010 "foo" => Value::test_string("3"),
1011 "bar" => Value::test_string("4"),
1012 }),
1013 Value::test_record(record! {
1014 "foo" => Value::test_string("5"),
1015 "bar" => Value::test_string("6"),
1016 }),
1017 ]);
1018
1019 let center: Option<Vec<CellPath>> = Some(vec![]);
1020
1021 assert_eq!(
1022 table(
1023 value.clone().into_pipeline_data(),
1024 true,
1025 ¢er,
1026 false,
1027 false,
1028 &Config::default()
1029 ),
1030 one("
1031 | foo | bar |
1032 | --- | --- |
1033 | 1 | 2 |
1034 | 3 | 4 |
1035 | 5 | 6 |
1036 ")
1037 );
1038 }
1039
1040 #[test]
1041 fn test_center_multiple_columns() {
1042 let value = Value::test_list(vec![
1043 Value::test_record(record! {
1044 "command" => Value::test_string("ls"),
1045 "input" => Value::test_string("."),
1046 "output" => Value::test_string("file.txt"),
1047 }),
1048 Value::test_record(record! {
1049 "command" => Value::test_string("echo"),
1050 "input" => Value::test_string("'hi'"),
1051 "output" => Value::test_string("hi"),
1052 }),
1053 Value::test_record(record! {
1054 "command" => Value::test_string("cp"),
1055 "input" => Value::test_string("a.txt"),
1056 "output" => Value::test_string("b.txt"),
1057 }),
1058 ]);
1059
1060 let center_columns = Value::test_list(vec![
1061 Value::test_cell_path(CellPath {
1062 members: vec![PathMember::test_string(
1063 "command".into(),
1064 false,
1065 Casing::Sensitive,
1066 )],
1067 }),
1068 Value::test_cell_path(CellPath {
1069 members: vec![PathMember::test_string(
1070 "output".into(),
1071 false,
1072 Casing::Sensitive,
1073 )],
1074 }),
1075 ]);
1076
1077 let cell_path: Vec<CellPath> = center_columns
1078 .into_list()
1079 .unwrap()
1080 .into_iter()
1081 .map(|v| v.into_cell_path().unwrap())
1082 .collect();
1083
1084 let center: Option<Vec<CellPath>> = Some(cell_path);
1085
1086 assert_eq!(
1087 table(
1088 value.clone().into_pipeline_data(),
1089 true,
1090 ¢er,
1091 false,
1092 false,
1093 &Config::default()
1094 ),
1095 one("
1096 | command | input | output |
1097 |:-------:| ----- |:--------:|
1098 | ls | . | file.txt |
1099 | echo | 'hi' | hi |
1100 | cp | a.txt | b.txt |
1101 ")
1102 );
1103 }
1104
1105 #[test]
1106 fn test_center_non_existing_column() {
1107 let value = Value::test_list(vec![
1108 Value::test_record(record! {
1109 "name" => Value::test_string("Alice"),
1110 "age" => Value::test_string("30"),
1111 }),
1112 Value::test_record(record! {
1113 "name" => Value::test_string("Bob"),
1114 "age" => Value::test_string("5"),
1115 }),
1116 Value::test_record(record! {
1117 "name" => Value::test_string("Charlie"),
1118 "age" => Value::test_string("20"),
1119 }),
1120 ]);
1121
1122 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1123 members: vec![PathMember::test_string(
1124 "none".into(),
1125 false,
1126 Casing::Sensitive,
1127 )],
1128 })]);
1129
1130 let cell_path: Vec<CellPath> = center_columns
1131 .into_list()
1132 .unwrap()
1133 .into_iter()
1134 .map(|v| v.into_cell_path().unwrap())
1135 .collect();
1136
1137 let center: Option<Vec<CellPath>> = Some(cell_path);
1138
1139 assert_eq!(
1140 table(
1141 value.clone().into_pipeline_data(),
1142 true,
1143 ¢er,
1144 false,
1145 false,
1146 &Config::default()
1147 ),
1148 one("
1149 | name | age |
1150 | ------- | --- |
1151 | Alice | 30 |
1152 | Bob | 5 |
1153 | Charlie | 20 |
1154 ")
1155 );
1156 }
1157
1158 #[test]
1159 fn test_center_complex_cell_path() {
1160 let value = Value::test_list(vec![
1161 Value::test_record(record! {
1162 "k" => Value::test_string("version"),
1163 "v" => Value::test_string("0.104.1"),
1164 }),
1165 Value::test_record(record! {
1166 "k" => Value::test_string("build_time"),
1167 "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1168 }),
1169 ]);
1170
1171 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1172 members: vec![
1173 PathMember::test_int(1, false),
1174 PathMember::test_string("v".into(), false, Casing::Sensitive),
1175 ],
1176 })]);
1177
1178 let cell_path: Vec<CellPath> = center_columns
1179 .into_list()
1180 .unwrap()
1181 .into_iter()
1182 .map(|v| v.into_cell_path().unwrap())
1183 .collect();
1184
1185 let center: Option<Vec<CellPath>> = Some(cell_path);
1186
1187 assert_eq!(
1188 table(
1189 value.clone().into_pipeline_data(),
1190 true,
1191 ¢er,
1192 false,
1193 false,
1194 &Config::default()
1195 ),
1196 one("
1197 | k | v |
1198 | ---------- |:--------------------------:|
1199 | version | 0.104.1 |
1200 | build_time | 2025-05-28 11:00:45 +01:00 |
1201 ")
1202 );
1203 }
1204
1205 #[test]
1206 fn test_content_type_metadata() {
1207 let mut engine_state = Box::new(EngineState::new());
1208 let state_delta = {
1209 let mut working_set = StateWorkingSet::new(&engine_state);
1212
1213 working_set.add_decl(Box::new(ToMd {}));
1214 working_set.add_decl(Box::new(Metadata {}));
1215 working_set.add_decl(Box::new(Get {}));
1216
1217 working_set.render()
1218 };
1219 let delta = state_delta;
1220
1221 engine_state
1222 .merge_delta(delta)
1223 .expect("Error merging delta");
1224
1225 let cmd = "{a: 1 b: 2} | to md | metadata | get content_type | $in";
1226 let result = eval_pipeline_without_terminal_expression(
1227 cmd,
1228 std::env::temp_dir().as_ref(),
1229 &mut engine_state,
1230 );
1231 assert_eq!(
1232 Value::test_string("text/markdown"),
1233 result.expect("There should be a result")
1234 );
1235 }
1236
1237 #[test]
1238 fn test_escape_md_characters() {
1239 let value = Value::test_list(vec![
1240 Value::test_record(record! {
1241 "name|label" => Value::test_string("orderColumns"),
1242 "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1243 }),
1244 Value::test_record(record! {
1245 "name|label" => Value::test_string("_ref_value"),
1246 "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1247 }),
1248 Value::test_record(record! {
1249 "name|label" => Value::test_string("onChange"),
1250 "type*" => Value::test_string("(val: string) => void\\"),
1251 }),
1252 ]);
1253
1254 assert_eq!(
1255 table(
1256 value.clone().into_pipeline_data(),
1257 false,
1258 &None,
1259 false,
1260 false,
1261 &Config::default()
1262 ),
1263 one(r#"
1264 | name\|label | type* |
1265 | --- | --- |
1266 | orderColumns | 'asc' \| 'desc' \| 'none' |
1267 | _ref_value | RefObject<SampleTableRef \| null> |
1268 | onChange | (val: string) => void\\ |
1269 "#)
1270 );
1271
1272 assert_eq!(
1273 table(
1274 value.clone().into_pipeline_data(),
1275 false,
1276 &None,
1277 true,
1278 false,
1279 &Config::default()
1280 ),
1281 one(r#"
1282 | name\|label | type\* |
1283 | --- | --- |
1284 | orderColumns | 'asc' \| 'desc' \| 'none' |
1285 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1286 | onChange | \(val: string\) =\> void\\ |
1287 "#)
1288 );
1289
1290 assert_eq!(
1291 table(
1292 value.clone().into_pipeline_data(),
1293 true,
1294 &None,
1295 false,
1296 false,
1297 &Config::default()
1298 ),
1299 one(r#"
1300 | name\|label | type* |
1301 | ------------ | --------------------------------- |
1302 | orderColumns | 'asc' \| 'desc' \| 'none' |
1303 | _ref_value | RefObject<SampleTableRef \| null> |
1304 | onChange | (val: string) => void\\ |
1305 "#)
1306 );
1307
1308 assert_eq!(
1309 table(
1310 value.into_pipeline_data(),
1311 true,
1312 &None,
1313 true,
1314 false,
1315 &Config::default()
1316 ),
1317 one(r#"
1318 | name\|label | type\* |
1319 | ------------ | ----------------------------------- |
1320 | orderColumns | 'asc' \| 'desc' \| 'none' |
1321 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1322 | onChange | \(val: string\) =\> void\\ |
1323 "#)
1324 );
1325 }
1326
1327 #[test]
1328 fn test_escape_html_characters() {
1329 let value = Value::test_list(vec![Value::test_record(record! {
1330 "tag" => Value::test_string("table"),
1331 "code" => Value::test_string(r#"<table><tr><td scope="row">Chris</td><td>HTML tables</td><td>22</td></tr><tr><td scope="row">Dennis</td><td>Web accessibility</td><td>45</td></tr></table>"#),
1332 })]);
1333
1334 assert_eq!(
1335 table(
1336 value.clone().into_pipeline_data(),
1337 false,
1338 &None,
1339 false,
1340 true,
1341 &Config::default()
1342 ),
1343 one("
1344 | tag | code |
1345 | --- | --- |
1346 | table | <table><tr><td scope="row">Chris</td><td>HTML tables</td><td>22</td></tr><tr><td scope="row">Dennis</td><td>Web accessibility</td><td>45</td></tr></table> |
1347 ")
1348 );
1349
1350 assert_eq!(
1351 table(
1352 value.into_pipeline_data(),
1353 true,
1354 &None,
1355 false,
1356 true,
1357 &Config::default()
1358 ),
1359 one("
1360 | tag | code |
1361 | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1362 | table | <table><tr><td scope="row">Chris</td><td>HTML tables</td><td>22</td></tr><tr><td scope="row">Dennis</td><td>Web accessibility</td><td>45</td></tr></table> |
1363 ")
1364 );
1365 }
1366
1367 #[test]
1368 fn test_list_ordered() {
1369 let value = Value::test_list(vec![
1370 Value::test_string("one"),
1371 Value::test_string("two"),
1372 Value::test_string("three"),
1373 ]);
1374
1375 let result = to_md(
1376 value.into_pipeline_data(),
1377 ToMdOptions {
1378 pretty: false,
1379 per_element: false,
1380 center: None,
1381 escape_md: false,
1382 escape_html: false,
1383 list_style: ListStyle::Ordered,
1384 },
1385 &Config::default(),
1386 Span::test_data(),
1387 )
1388 .unwrap()
1389 .into_value(Span::test_data())
1390 .unwrap()
1391 .into_string()
1392 .unwrap();
1393
1394 assert_eq!(result, "1. one\n2. two\n3. three");
1395 }
1396
1397 #[test]
1398 fn test_list_unordered() {
1399 let value = Value::test_list(vec![
1400 Value::test_string("apple"),
1401 Value::test_string("banana"),
1402 Value::test_string("cherry"),
1403 ]);
1404
1405 let result = to_md(
1406 value.into_pipeline_data(),
1407 ToMdOptions {
1408 pretty: false,
1409 per_element: false,
1410 center: None,
1411 escape_md: false,
1412 escape_html: false,
1413 list_style: ListStyle::Unordered,
1414 },
1415 &Config::default(),
1416 Span::test_data(),
1417 )
1418 .unwrap()
1419 .into_value(Span::test_data())
1420 .unwrap()
1421 .into_string()
1422 .unwrap();
1423
1424 assert_eq!(result, "* apple\n* banana\n* cherry");
1425 }
1426
1427 #[test]
1428 fn test_list_with_escape_md() {
1429 let value = Value::test_list(vec![
1430 Value::test_string("*bold*"),
1431 Value::test_string("[link]"),
1432 ]);
1433
1434 let result = to_md(
1435 value.into_pipeline_data(),
1436 ToMdOptions {
1437 pretty: false,
1438 per_element: false,
1439 center: None,
1440 escape_md: true,
1441 escape_html: false,
1442 list_style: ListStyle::Unordered,
1443 },
1444 &Config::default(),
1445 Span::test_data(),
1446 )
1447 .unwrap()
1448 .into_value(Span::test_data())
1449 .unwrap()
1450 .into_string()
1451 .unwrap();
1452
1453 assert_eq!(result, "* \\*bold\\*\n* \\[link\\]");
1454 }
1455
1456 #[test]
1457 fn test_list_none() {
1458 let value = Value::test_list(vec![
1459 Value::test_string("one"),
1460 Value::test_string("two"),
1461 Value::test_string("three"),
1462 ]);
1463
1464 let result = to_md(
1465 value.into_pipeline_data(),
1466 ToMdOptions {
1467 pretty: false,
1468 per_element: false,
1469 center: None,
1470 escape_md: false,
1471 escape_html: false,
1472 list_style: ListStyle::None,
1473 },
1474 &Config::default(),
1475 Span::test_data(),
1476 )
1477 .unwrap()
1478 .into_value(Span::test_data())
1479 .unwrap()
1480 .into_string()
1481 .unwrap();
1482
1483 assert_eq!(result, "one\ntwo\nthree");
1484 }
1485
1486 #[test]
1487 fn test_empty_list() {
1488 let value = Value::test_list(vec![]);
1489
1490 let result = to_md(
1491 value.into_pipeline_data(),
1492 ToMdOptions {
1493 pretty: false,
1494 per_element: false,
1495 center: None,
1496 escape_md: false,
1497 escape_html: false,
1498 list_style: ListStyle::Unordered,
1499 },
1500 &Config::default(),
1501 Span::test_data(),
1502 )
1503 .unwrap()
1504 .into_value(Span::test_data())
1505 .unwrap()
1506 .into_string()
1507 .unwrap();
1508
1509 assert_eq!(result, "");
1510 }
1511
1512 #[test]
1513 fn test_mixed_input_ordered() {
1514 let value = Value::test_list(vec![
1516 Value::test_record(record! {
1517 "h1" => Value::test_string("Title"),
1518 }),
1519 Value::test_string("first"),
1520 Value::test_string("second"),
1521 ]);
1522
1523 let result = to_md(
1524 value.into_pipeline_data(),
1525 ToMdOptions {
1526 pretty: false,
1527 per_element: true,
1528 center: None,
1529 escape_md: false,
1530 escape_html: false,
1531 list_style: ListStyle::Ordered,
1532 },
1533 &Config::default(),
1534 Span::test_data(),
1535 )
1536 .unwrap()
1537 .into_value(Span::test_data())
1538 .unwrap()
1539 .into_string()
1540 .unwrap();
1541
1542 assert_eq!(result, "# Title\n1. first\n2. second");
1544 }
1545}