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() {
760 use crate::test_examples;
761
762 test_examples(ToMd {})
763 }
764
765 #[test]
766 fn render_h1() {
767 let value = Value::test_record(record! {
768 "H1" => Value::test_string("Ecuador"),
769 });
770
771 assert_eq!(
772 fragment(value, false, &None, false, false, &Config::default()),
773 "# Ecuador\n"
774 );
775 }
776
777 #[test]
778 fn render_h2() {
779 let value = Value::test_record(record! {
780 "H2" => Value::test_string("Ecuador"),
781 });
782
783 assert_eq!(
784 fragment(value, false, &None, false, false, &Config::default()),
785 "## Ecuador\n"
786 );
787 }
788
789 #[test]
790 fn render_h3() {
791 let value = Value::test_record(record! {
792 "H3" => Value::test_string("Ecuador"),
793 });
794
795 assert_eq!(
796 fragment(value, false, &None, false, false, &Config::default()),
797 "### Ecuador\n"
798 );
799 }
800
801 #[test]
802 fn render_blockquote() {
803 let value = Value::test_record(record! {
804 "BLOCKQUOTE" => Value::test_string("Ecuador"),
805 });
806
807 assert_eq!(
808 fragment(value, false, &None, false, false, &Config::default()),
809 "> Ecuador\n"
810 );
811 }
812
813 #[test]
814 fn render_table() {
815 let value = Value::test_list(vec![
816 Value::test_record(record! {
817 "country" => Value::test_string("Ecuador"),
818 }),
819 Value::test_record(record! {
820 "country" => Value::test_string("New Zealand"),
821 }),
822 Value::test_record(record! {
823 "country" => Value::test_string("USA"),
824 }),
825 ]);
826
827 assert_eq!(
828 table(
829 value.clone().into_pipeline_data(),
830 false,
831 &None,
832 false,
833 false,
834 &Config::default()
835 ),
836 one(r#"
837 | country |
838 | --- |
839 | Ecuador |
840 | New Zealand |
841 | USA |
842 "#)
843 );
844
845 assert_eq!(
846 table(
847 value.into_pipeline_data(),
848 true,
849 &None,
850 false,
851 false,
852 &Config::default()
853 ),
854 one(r#"
855 | country |
856 | ----------- |
857 | Ecuador |
858 | New Zealand |
859 | USA |
860 "#)
861 );
862 }
863
864 #[test]
865 fn test_empty_column_header() {
866 let value = Value::test_list(vec![
867 Value::test_record(record! {
868 "" => Value::test_string("1"),
869 "foo" => Value::test_string("2"),
870 }),
871 Value::test_record(record! {
872 "" => Value::test_string("3"),
873 "foo" => Value::test_string("4"),
874 }),
875 ]);
876
877 assert_eq!(
878 table(
879 value.clone().into_pipeline_data(),
880 false,
881 &None,
882 false,
883 false,
884 &Config::default()
885 ),
886 one(r#"
887 | | foo |
888 | --- | --- |
889 | 1 | 2 |
890 | 3 | 4 |
891 "#)
892 );
893 }
894
895 #[test]
896 fn test_empty_row_value() {
897 let value = Value::test_list(vec![
898 Value::test_record(record! {
899 "foo" => Value::test_string("1"),
900 "bar" => Value::test_string("2"),
901 }),
902 Value::test_record(record! {
903 "foo" => Value::test_string("3"),
904 "bar" => Value::test_string("4"),
905 }),
906 Value::test_record(record! {
907 "foo" => Value::test_string("5"),
908 "bar" => Value::test_string(""),
909 }),
910 ]);
911
912 assert_eq!(
913 table(
914 value.clone().into_pipeline_data(),
915 false,
916 &None,
917 false,
918 false,
919 &Config::default()
920 ),
921 one(r#"
922 | foo | bar |
923 | --- | --- |
924 | 1 | 2 |
925 | 3 | 4 |
926 | 5 | |
927 "#)
928 );
929 }
930
931 #[test]
932 fn test_center_column() {
933 let value = Value::test_list(vec![
934 Value::test_record(record! {
935 "foo" => Value::test_string("1"),
936 "bar" => Value::test_string("2"),
937 }),
938 Value::test_record(record! {
939 "foo" => Value::test_string("3"),
940 "bar" => Value::test_string("4"),
941 }),
942 Value::test_record(record! {
943 "foo" => Value::test_string("5"),
944 "bar" => Value::test_string("6"),
945 }),
946 ]);
947
948 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
949 members: vec![PathMember::test_string(
950 "bar".into(),
951 false,
952 Casing::Sensitive,
953 )],
954 })]);
955
956 let cell_path: Vec<CellPath> = center_columns
957 .into_list()
958 .unwrap()
959 .into_iter()
960 .map(|v| v.into_cell_path().unwrap())
961 .collect();
962
963 let center: Option<Vec<CellPath>> = Some(cell_path);
964
965 assert_eq!(
967 table(
968 value.clone().into_pipeline_data(),
969 true,
970 ¢er,
971 false,
972 false,
973 &Config::default()
974 ),
975 one(r#"
976 | foo | bar |
977 | --- |:---:|
978 | 1 | 2 |
979 | 3 | 4 |
980 | 5 | 6 |
981 "#)
982 );
983
984 assert_eq!(
986 table(
987 value.clone().into_pipeline_data(),
988 false,
989 ¢er,
990 false,
991 false,
992 &Config::default()
993 ),
994 one(r#"
995 | foo | bar |
996 | --- |:---:|
997 | 1 | 2 |
998 | 3 | 4 |
999 | 5 | 6 |
1000 "#)
1001 );
1002 }
1003
1004 #[test]
1005 fn test_empty_center_column() {
1006 let value = Value::test_list(vec![
1007 Value::test_record(record! {
1008 "foo" => Value::test_string("1"),
1009 "bar" => Value::test_string("2"),
1010 }),
1011 Value::test_record(record! {
1012 "foo" => Value::test_string("3"),
1013 "bar" => Value::test_string("4"),
1014 }),
1015 Value::test_record(record! {
1016 "foo" => Value::test_string("5"),
1017 "bar" => Value::test_string("6"),
1018 }),
1019 ]);
1020
1021 let center: Option<Vec<CellPath>> = Some(vec![]);
1022
1023 assert_eq!(
1024 table(
1025 value.clone().into_pipeline_data(),
1026 true,
1027 ¢er,
1028 false,
1029 false,
1030 &Config::default()
1031 ),
1032 one(r#"
1033 | foo | bar |
1034 | --- | --- |
1035 | 1 | 2 |
1036 | 3 | 4 |
1037 | 5 | 6 |
1038 "#)
1039 );
1040 }
1041
1042 #[test]
1043 fn test_center_multiple_columns() {
1044 let value = Value::test_list(vec![
1045 Value::test_record(record! {
1046 "command" => Value::test_string("ls"),
1047 "input" => Value::test_string("."),
1048 "output" => Value::test_string("file.txt"),
1049 }),
1050 Value::test_record(record! {
1051 "command" => Value::test_string("echo"),
1052 "input" => Value::test_string("'hi'"),
1053 "output" => Value::test_string("hi"),
1054 }),
1055 Value::test_record(record! {
1056 "command" => Value::test_string("cp"),
1057 "input" => Value::test_string("a.txt"),
1058 "output" => Value::test_string("b.txt"),
1059 }),
1060 ]);
1061
1062 let center_columns = Value::test_list(vec![
1063 Value::test_cell_path(CellPath {
1064 members: vec![PathMember::test_string(
1065 "command".into(),
1066 false,
1067 Casing::Sensitive,
1068 )],
1069 }),
1070 Value::test_cell_path(CellPath {
1071 members: vec![PathMember::test_string(
1072 "output".into(),
1073 false,
1074 Casing::Sensitive,
1075 )],
1076 }),
1077 ]);
1078
1079 let cell_path: Vec<CellPath> = center_columns
1080 .into_list()
1081 .unwrap()
1082 .into_iter()
1083 .map(|v| v.into_cell_path().unwrap())
1084 .collect();
1085
1086 let center: Option<Vec<CellPath>> = Some(cell_path);
1087
1088 assert_eq!(
1089 table(
1090 value.clone().into_pipeline_data(),
1091 true,
1092 ¢er,
1093 false,
1094 false,
1095 &Config::default()
1096 ),
1097 one(r#"
1098 | command | input | output |
1099 |:-------:| ----- |:--------:|
1100 | ls | . | file.txt |
1101 | echo | 'hi' | hi |
1102 | cp | a.txt | b.txt |
1103 "#)
1104 );
1105 }
1106
1107 #[test]
1108 fn test_center_non_existing_column() {
1109 let value = Value::test_list(vec![
1110 Value::test_record(record! {
1111 "name" => Value::test_string("Alice"),
1112 "age" => Value::test_string("30"),
1113 }),
1114 Value::test_record(record! {
1115 "name" => Value::test_string("Bob"),
1116 "age" => Value::test_string("5"),
1117 }),
1118 Value::test_record(record! {
1119 "name" => Value::test_string("Charlie"),
1120 "age" => Value::test_string("20"),
1121 }),
1122 ]);
1123
1124 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1125 members: vec![PathMember::test_string(
1126 "none".into(),
1127 false,
1128 Casing::Sensitive,
1129 )],
1130 })]);
1131
1132 let cell_path: Vec<CellPath> = center_columns
1133 .into_list()
1134 .unwrap()
1135 .into_iter()
1136 .map(|v| v.into_cell_path().unwrap())
1137 .collect();
1138
1139 let center: Option<Vec<CellPath>> = Some(cell_path);
1140
1141 assert_eq!(
1142 table(
1143 value.clone().into_pipeline_data(),
1144 true,
1145 ¢er,
1146 false,
1147 false,
1148 &Config::default()
1149 ),
1150 one(r#"
1151 | name | age |
1152 | ------- | --- |
1153 | Alice | 30 |
1154 | Bob | 5 |
1155 | Charlie | 20 |
1156 "#)
1157 );
1158 }
1159
1160 #[test]
1161 fn test_center_complex_cell_path() {
1162 let value = Value::test_list(vec![
1163 Value::test_record(record! {
1164 "k" => Value::test_string("version"),
1165 "v" => Value::test_string("0.104.1"),
1166 }),
1167 Value::test_record(record! {
1168 "k" => Value::test_string("build_time"),
1169 "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1170 }),
1171 ]);
1172
1173 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1174 members: vec![
1175 PathMember::test_int(1, false),
1176 PathMember::test_string("v".into(), false, Casing::Sensitive),
1177 ],
1178 })]);
1179
1180 let cell_path: Vec<CellPath> = center_columns
1181 .into_list()
1182 .unwrap()
1183 .into_iter()
1184 .map(|v| v.into_cell_path().unwrap())
1185 .collect();
1186
1187 let center: Option<Vec<CellPath>> = Some(cell_path);
1188
1189 assert_eq!(
1190 table(
1191 value.clone().into_pipeline_data(),
1192 true,
1193 ¢er,
1194 false,
1195 false,
1196 &Config::default()
1197 ),
1198 one(r#"
1199 | k | v |
1200 | ---------- |:--------------------------:|
1201 | version | 0.104.1 |
1202 | build_time | 2025-05-28 11:00:45 +01:00 |
1203 "#)
1204 );
1205 }
1206
1207 #[test]
1208 fn test_content_type_metadata() {
1209 let mut engine_state = Box::new(EngineState::new());
1210 let state_delta = {
1211 let mut working_set = StateWorkingSet::new(&engine_state);
1214
1215 working_set.add_decl(Box::new(ToMd {}));
1216 working_set.add_decl(Box::new(Metadata {}));
1217 working_set.add_decl(Box::new(Get {}));
1218
1219 working_set.render()
1220 };
1221 let delta = state_delta;
1222
1223 engine_state
1224 .merge_delta(delta)
1225 .expect("Error merging delta");
1226
1227 let cmd = "{a: 1 b: 2} | to md | metadata | get content_type | $in";
1228 let result = eval_pipeline_without_terminal_expression(
1229 cmd,
1230 std::env::temp_dir().as_ref(),
1231 &mut engine_state,
1232 );
1233 assert_eq!(
1234 Value::test_string("text/markdown"),
1235 result.expect("There should be a result")
1236 );
1237 }
1238
1239 #[test]
1240 fn test_escape_md_characters() {
1241 let value = Value::test_list(vec![
1242 Value::test_record(record! {
1243 "name|label" => Value::test_string("orderColumns"),
1244 "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1245 }),
1246 Value::test_record(record! {
1247 "name|label" => Value::test_string("_ref_value"),
1248 "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1249 }),
1250 Value::test_record(record! {
1251 "name|label" => Value::test_string("onChange"),
1252 "type*" => Value::test_string("(val: string) => void\\"),
1253 }),
1254 ]);
1255
1256 assert_eq!(
1257 table(
1258 value.clone().into_pipeline_data(),
1259 false,
1260 &None,
1261 false,
1262 false,
1263 &Config::default()
1264 ),
1265 one(r#"
1266 | name\|label | type* |
1267 | --- | --- |
1268 | orderColumns | 'asc' \| 'desc' \| 'none' |
1269 | _ref_value | RefObject<SampleTableRef \| null> |
1270 | onChange | (val: string) => void\\ |
1271 "#)
1272 );
1273
1274 assert_eq!(
1275 table(
1276 value.clone().into_pipeline_data(),
1277 false,
1278 &None,
1279 true,
1280 false,
1281 &Config::default()
1282 ),
1283 one(r#"
1284 | name\|label | type\* |
1285 | --- | --- |
1286 | orderColumns | 'asc' \| 'desc' \| 'none' |
1287 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1288 | onChange | \(val: string\) =\> void\\ |
1289 "#)
1290 );
1291
1292 assert_eq!(
1293 table(
1294 value.clone().into_pipeline_data(),
1295 true,
1296 &None,
1297 false,
1298 false,
1299 &Config::default()
1300 ),
1301 one(r#"
1302 | name\|label | type* |
1303 | ------------ | --------------------------------- |
1304 | orderColumns | 'asc' \| 'desc' \| 'none' |
1305 | _ref_value | RefObject<SampleTableRef \| null> |
1306 | onChange | (val: string) => void\\ |
1307 "#)
1308 );
1309
1310 assert_eq!(
1311 table(
1312 value.into_pipeline_data(),
1313 true,
1314 &None,
1315 true,
1316 false,
1317 &Config::default()
1318 ),
1319 one(r#"
1320 | name\|label | type\* |
1321 | ------------ | ----------------------------------- |
1322 | orderColumns | 'asc' \| 'desc' \| 'none' |
1323 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1324 | onChange | \(val: string\) =\> void\\ |
1325 "#)
1326 );
1327 }
1328
1329 #[test]
1330 fn test_escape_html_characters() {
1331 let value = Value::test_list(vec![Value::test_record(record! {
1332 "tag" => Value::test_string("table"),
1333 "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>"#),
1334 })]);
1335
1336 assert_eq!(
1337 table(
1338 value.clone().into_pipeline_data(),
1339 false,
1340 &None,
1341 false,
1342 true,
1343 &Config::default()
1344 ),
1345 one(r#"
1346 | tag | code |
1347 | --- | --- |
1348 | 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> |
1349 "#)
1350 );
1351
1352 assert_eq!(
1353 table(
1354 value.into_pipeline_data(),
1355 true,
1356 &None,
1357 false,
1358 true,
1359 &Config::default()
1360 ),
1361 one(r#"
1362 | tag | code |
1363 | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1364 | 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> |
1365 "#)
1366 );
1367 }
1368
1369 #[test]
1370 fn test_list_ordered() {
1371 let value = Value::test_list(vec![
1372 Value::test_string("one"),
1373 Value::test_string("two"),
1374 Value::test_string("three"),
1375 ]);
1376
1377 let result = to_md(
1378 value.into_pipeline_data(),
1379 ToMdOptions {
1380 pretty: false,
1381 per_element: false,
1382 center: None,
1383 escape_md: false,
1384 escape_html: false,
1385 list_style: ListStyle::Ordered,
1386 },
1387 &Config::default(),
1388 Span::test_data(),
1389 )
1390 .unwrap()
1391 .into_value(Span::test_data())
1392 .unwrap()
1393 .into_string()
1394 .unwrap();
1395
1396 assert_eq!(result, "1. one\n2. two\n3. three");
1397 }
1398
1399 #[test]
1400 fn test_list_unordered() {
1401 let value = Value::test_list(vec![
1402 Value::test_string("apple"),
1403 Value::test_string("banana"),
1404 Value::test_string("cherry"),
1405 ]);
1406
1407 let result = to_md(
1408 value.into_pipeline_data(),
1409 ToMdOptions {
1410 pretty: false,
1411 per_element: false,
1412 center: None,
1413 escape_md: false,
1414 escape_html: false,
1415 list_style: ListStyle::Unordered,
1416 },
1417 &Config::default(),
1418 Span::test_data(),
1419 )
1420 .unwrap()
1421 .into_value(Span::test_data())
1422 .unwrap()
1423 .into_string()
1424 .unwrap();
1425
1426 assert_eq!(result, "* apple\n* banana\n* cherry");
1427 }
1428
1429 #[test]
1430 fn test_list_with_escape_md() {
1431 let value = Value::test_list(vec![
1432 Value::test_string("*bold*"),
1433 Value::test_string("[link]"),
1434 ]);
1435
1436 let result = to_md(
1437 value.into_pipeline_data(),
1438 ToMdOptions {
1439 pretty: false,
1440 per_element: false,
1441 center: None,
1442 escape_md: true,
1443 escape_html: false,
1444 list_style: ListStyle::Unordered,
1445 },
1446 &Config::default(),
1447 Span::test_data(),
1448 )
1449 .unwrap()
1450 .into_value(Span::test_data())
1451 .unwrap()
1452 .into_string()
1453 .unwrap();
1454
1455 assert_eq!(result, "* \\*bold\\*\n* \\[link\\]");
1456 }
1457
1458 #[test]
1459 fn test_list_none() {
1460 let value = Value::test_list(vec![
1461 Value::test_string("one"),
1462 Value::test_string("two"),
1463 Value::test_string("three"),
1464 ]);
1465
1466 let result = to_md(
1467 value.into_pipeline_data(),
1468 ToMdOptions {
1469 pretty: false,
1470 per_element: false,
1471 center: None,
1472 escape_md: false,
1473 escape_html: false,
1474 list_style: ListStyle::None,
1475 },
1476 &Config::default(),
1477 Span::test_data(),
1478 )
1479 .unwrap()
1480 .into_value(Span::test_data())
1481 .unwrap()
1482 .into_string()
1483 .unwrap();
1484
1485 assert_eq!(result, "one\ntwo\nthree");
1486 }
1487
1488 #[test]
1489 fn test_empty_list() {
1490 let value = Value::test_list(vec![]);
1491
1492 let result = to_md(
1493 value.into_pipeline_data(),
1494 ToMdOptions {
1495 pretty: false,
1496 per_element: false,
1497 center: None,
1498 escape_md: false,
1499 escape_html: false,
1500 list_style: ListStyle::Unordered,
1501 },
1502 &Config::default(),
1503 Span::test_data(),
1504 )
1505 .unwrap()
1506 .into_value(Span::test_data())
1507 .unwrap()
1508 .into_string()
1509 .unwrap();
1510
1511 assert_eq!(result, "");
1512 }
1513
1514 #[test]
1515 fn test_mixed_input_ordered() {
1516 let value = Value::test_list(vec![
1518 Value::test_record(record! {
1519 "h1" => Value::test_string("Title"),
1520 }),
1521 Value::test_string("first"),
1522 Value::test_string("second"),
1523 ]);
1524
1525 let result = to_md(
1526 value.into_pipeline_data(),
1527 ToMdOptions {
1528 pretty: false,
1529 per_element: true,
1530 center: None,
1531 escape_md: false,
1532 escape_html: false,
1533 list_style: ListStyle::Ordered,
1534 },
1535 &Config::default(),
1536 Span::test_data(),
1537 )
1538 .unwrap()
1539 .into_value(Span::test_data())
1540 .unwrap()
1541 .into_string()
1542 .unwrap();
1543
1544 assert_eq!(result, "# Title\n1. first\n2. second");
1546 }
1547}