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 mut input: PipelineData,
220 options: ToMdOptions,
221 config: &Config,
222 head: Span,
223) -> Result<PipelineData, ShellError> {
224 let metadata = Some(
226 input
227 .take_metadata()
228 .unwrap_or_default()
229 .with_content_type(Some("text/markdown".into())),
230 );
231
232 let values: Vec<Value> = input.into_iter().collect();
234
235 let is_simple_list = !values
238 .iter()
239 .any(|v| matches!(v, Value::Record { .. } | Value::List { .. }));
240
241 if is_simple_list {
243 let result = values
244 .into_iter()
245 .enumerate()
246 .map(|(idx, val)| {
247 format_list_item(
248 val,
249 idx,
250 options.list_style,
251 options.escape_md,
252 options.escape_html,
253 config,
254 )
255 })
256 .collect::<Vec<String>>()
257 .join("")
258 .trim()
259 .to_string();
260 return Ok(Value::string(result, head).into_pipeline_data_with_metadata(metadata));
261 }
262
263 let input = Value::list(values, head).into_pipeline_data();
265 let (grouped_input, single_list) = group_by(input, head, config);
266 if options.per_element || single_list {
267 return Ok(Value::string(
268 grouped_input
269 .into_iter()
270 .scan(0usize, |list_idx, val| {
271 Some(match &val {
272 Value::List { .. } => {
273 format!(
274 "{}\n\n",
275 table(
276 val.into_pipeline_data(),
277 options.pretty,
278 &options.center,
279 options.escape_md,
280 options.escape_html,
281 config
282 )
283 )
284 }
285 Value::Record { val: record, .. } => {
287 if is_special_markdown_record(record) {
288 fragment(
290 val,
291 options.pretty,
292 &options.center,
293 options.escape_md,
294 options.escape_html,
295 config,
296 )
297 } else {
298 format!(
300 "{}\n\n",
301 fragment(
302 val,
303 options.pretty,
304 &options.center,
305 options.escape_md,
306 options.escape_html,
307 config
308 )
309 )
310 }
311 }
312 _ => {
313 let result = format_list_item(
314 val,
315 *list_idx,
316 options.list_style,
317 options.escape_md,
318 options.escape_html,
319 config,
320 );
321 *list_idx += 1;
322 result
323 }
324 })
325 })
326 .collect::<Vec<String>>()
327 .join("")
328 .trim(),
329 head,
330 )
331 .into_pipeline_data_with_metadata(metadata));
332 }
333 Ok(Value::string(
334 table(
335 grouped_input,
336 options.pretty,
337 &options.center,
338 options.escape_md,
339 options.escape_html,
340 config,
341 ),
342 head,
343 )
344 .into_pipeline_data_with_metadata(metadata))
345}
346
347fn format_list_item(
349 input: Value,
350 index: usize,
351 list_style: ListStyle,
352 escape_md: bool,
353 escape_html: bool,
354 config: &Config,
355) -> String {
356 let value_string = input.to_expanded_string("|", config);
357 let escaped = escape_value(value_string, escape_md, escape_html, false);
358
359 match list_style {
360 ListStyle::Ordered => format!("{}. {}\n", index + 1, escaped),
361 ListStyle::Unordered => format!("* {}\n", escaped),
362 ListStyle::None => format!("{}\n", escaped),
363 }
364}
365
366fn escape_markdown_characters(input: String, escape_md: bool, for_table: bool) -> String {
367 let mut output = String::with_capacity(input.len());
368 for ch in input.chars() {
369 let must_escape = match ch {
370 '\\' => true,
371 '|' if for_table => true,
372 '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '<' | '>' | '#' | '+' | '-'
373 | '.' | '!'
374 if escape_md =>
375 {
376 true
377 }
378 _ => false,
379 };
380
381 if must_escape {
382 output.push('\\');
383 }
384 output.push(ch);
385 }
386 output
387}
388
389fn escape_value(value: String, escape_md: bool, escape_html: bool, for_table: bool) -> String {
391 escape_markdown_characters(
392 if escape_html {
393 v_htmlescape::escape(&value).to_string()
394 } else {
395 value
396 },
397 escape_md,
398 for_table,
399 )
400}
401
402fn fragment(
403 input: Value,
404 pretty: bool,
405 center: &Option<Vec<CellPath>>,
406 escape_md: bool,
407 escape_html: bool,
408 config: &Config,
409) -> String {
410 let mut out = String::new();
411
412 if let Value::Record { val, .. } = &input {
413 match val.get_index(0) {
414 Some((header, data)) if is_special_markdown_record(val) => {
415 let markup = match header.to_ascii_lowercase().as_ref() {
417 "h1" => "# ",
418 "h2" => "## ",
419 "h3" => "### ",
420 "blockquote" => "> ",
421 _ => "> ", };
423
424 let value_string = data.to_expanded_string("|", config);
425 out.push_str(markup);
426 out.push_str(&escape_value(value_string, escape_md, escape_html, false));
427 }
428 _ => {
429 out = table(
430 input.into_pipeline_data(),
431 pretty,
432 center,
433 escape_md,
434 escape_html,
435 config,
436 )
437 }
438 }
439 } else {
440 let value_string = input.to_expanded_string("|", config);
441 out = escape_value(value_string, escape_md, escape_html, false);
442 }
443
444 out.push('\n');
445 out
446}
447
448fn collect_headers(headers: &[String], escape_md: bool) -> (Vec<String>, Vec<usize>) {
449 let mut escaped_headers: Vec<String> = Vec::new();
450 let mut column_widths: Vec<usize> = Vec::new();
451
452 if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
453 for header in headers {
454 let escaped_header_string = escape_markdown_characters(
455 v_htmlescape::escape(header).to_string(),
456 escape_md,
457 true,
458 );
459 column_widths.push(escaped_header_string.len());
460 escaped_headers.push(escaped_header_string);
461 }
462 } else {
463 column_widths = vec![0; headers.len()];
464 }
465
466 (escaped_headers, column_widths)
467}
468
469fn table(
470 input: PipelineData,
471 pretty: bool,
472 center: &Option<Vec<CellPath>>,
473 escape_md: bool,
474 escape_html: bool,
475 config: &Config,
476) -> String {
477 let vec_of_values = input
478 .into_iter()
479 .flat_map(|val| match val {
480 Value::List { vals, .. } => vals,
481 other => vec![other],
482 })
483 .collect::<Vec<Value>>();
484 let mut headers = merge_descriptors(&vec_of_values);
485
486 let mut empty_header_index = 0;
487 for value in &vec_of_values {
488 if let Value::Record { val, .. } = value {
489 for column in val.columns() {
490 if column.is_empty() && !headers.contains(&String::new()) {
491 headers.insert(empty_header_index, String::new());
492 empty_header_index += 1;
493 break;
494 }
495 empty_header_index += 1;
496 }
497 }
498 }
499
500 let (escaped_headers, mut column_widths) = collect_headers(&headers, escape_md);
501
502 let mut escaped_rows: Vec<Vec<String>> = Vec::new();
503
504 for row in vec_of_values {
505 let mut escaped_row: Vec<String> = Vec::new();
506 let span = row.span();
507
508 match row.to_owned() {
509 Value::Record { val: row, .. } => {
510 for i in 0..headers.len() {
511 let value_string = row
512 .get(&headers[i])
513 .cloned()
514 .unwrap_or_else(|| Value::nothing(span))
515 .to_expanded_string(", ", config);
516 let escaped_string = escape_markdown_characters(
517 if escape_html {
518 v_htmlescape::escape(&value_string).to_string()
519 } else {
520 value_string
521 },
522 escape_md,
523 true,
524 );
525
526 let new_column_width = escaped_string.len();
527 escaped_row.push(escaped_string);
528
529 if column_widths[i] < new_column_width {
530 column_widths[i] = new_column_width;
531 }
532 if column_widths[i] < 3 {
533 column_widths[i] = 3;
534 }
535 }
536 }
537 p => {
538 let value_string =
539 v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
540 escaped_row.push(value_string);
541 }
542 }
543
544 escaped_rows.push(escaped_row);
545 }
546
547 if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
548 && escaped_rows.is_empty()
549 {
550 String::from("")
551 } else {
552 get_output_string(
553 &escaped_headers,
554 &escaped_rows,
555 &column_widths,
556 pretty,
557 center,
558 )
559 .trim()
560 .to_string()
561 }
562}
563
564pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
565 let mut lists = IndexMap::new();
566 let mut single_list = false;
567 for val in values {
568 if let Value::Record {
569 val: ref record, ..
570 } = val
571 {
572 lists
573 .entry(record.columns().map(|c| c.as_str()).collect::<String>())
574 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
575 .or_insert_with(|| vec![val.clone()]);
576 } else {
577 lists
578 .entry(val.to_expanded_string(",", config))
579 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
580 .or_insert_with(|| vec![val.clone()]);
581 }
582 }
583 let mut output = vec![];
584 for (_, mut value) in lists {
585 if value.len() == 1 {
586 output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
587 } else {
588 output.push(Value::list(value.to_vec(), head))
589 }
590 }
591 if output.len() == 1 {
592 single_list = true;
593 }
594 (Value::list(output, head).into_pipeline_data(), single_list)
595}
596
597fn get_output_string(
598 headers: &[String],
599 rows: &[Vec<String>],
600 column_widths: &[usize],
601 pretty: bool,
602 center: &Option<Vec<CellPath>>,
603) -> String {
604 let mut output_string = String::new();
605
606 let mut to_center: HashSet<String> = HashSet::new();
607 if let Some(center_vec) = center.as_ref() {
608 for cell_path in center_vec {
609 if let Some(PathMember::String { val, .. }) = cell_path
610 .members
611 .iter()
612 .find(|member| matches!(member, PathMember::String { .. }))
613 {
614 to_center.insert(val.clone());
615 }
616 }
617 }
618
619 if !headers.is_empty() {
620 output_string.push('|');
621
622 for i in 0..headers.len() {
623 output_string.push(' ');
624 if pretty {
625 if center.is_some() && to_center.contains(&headers[i]) {
626 output_string.push_str(&get_centered_string(
627 headers[i].clone(),
628 column_widths[i],
629 ' ',
630 ));
631 } else {
632 output_string.push_str(&get_padded_string(
633 headers[i].clone(),
634 column_widths[i],
635 ' ',
636 ));
637 }
638 } else {
639 output_string.push_str(&headers[i]);
640 }
641
642 output_string.push_str(" |");
643 }
644
645 output_string.push_str("\n|");
646
647 for i in 0..headers.len() {
648 let centered_column = center.is_some() && to_center.contains(&headers[i]);
649 let border_char = if centered_column { ':' } else { ' ' };
650 if pretty {
651 output_string.push(border_char);
652 output_string.push_str(&get_padded_string(
653 String::from("-"),
654 column_widths[i],
655 '-',
656 ));
657 output_string.push(border_char);
658 } else if centered_column {
659 output_string.push_str(":---:");
660 } else {
661 output_string.push_str(" --- ");
662 }
663
664 output_string.push('|');
665 }
666
667 output_string.push('\n');
668 }
669
670 for row in rows {
671 if !headers.is_empty() {
672 output_string.push('|');
673 }
674
675 for i in 0..row.len() {
676 if !headers.is_empty() {
677 output_string.push(' ');
678 }
679
680 if pretty && column_widths.get(i).is_some() {
681 if center.is_some() && to_center.contains(&headers[i]) {
682 output_string.push_str(&get_centered_string(
683 row[i].clone(),
684 column_widths[i],
685 ' ',
686 ));
687 } else {
688 output_string.push_str(&get_padded_string(
689 row[i].clone(),
690 column_widths[i],
691 ' ',
692 ));
693 }
694 } else {
695 output_string.push_str(&row[i]);
696 }
697
698 if !headers.is_empty() {
699 output_string.push_str(" |");
700 }
701 }
702
703 output_string.push('\n');
704 }
705
706 output_string
707}
708
709fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
710 let total_padding = if text.len() > desired_length {
711 0
712 } else {
713 desired_length - text.len()
714 };
715
716 let repeat_left = total_padding / 2;
717 let repeat_right = total_padding - repeat_left;
718
719 format!(
720 "{}{}{}",
721 padding_character.to_string().repeat(repeat_left),
722 text,
723 padding_character.to_string().repeat(repeat_right)
724 )
725}
726
727fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
728 let repeat_length = if text.len() > desired_length {
729 0
730 } else {
731 desired_length - text.len()
732 };
733
734 format!(
735 "{}{}",
736 text,
737 padding_character.to_string().repeat(repeat_length)
738 )
739}
740
741#[cfg(test)]
742mod tests {
743 use crate::{Get, Metadata};
744
745 use super::*;
746 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
747 use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
748
749 fn one(string: &str) -> String {
750 string
751 .lines()
752 .skip(1)
753 .map(|line| line.trim())
754 .collect::<Vec<&str>>()
755 .join("\n")
756 .trim_end()
757 .to_string()
758 }
759
760 #[test]
761 fn test_examples() -> nu_test_support::Result {
762 nu_test_support::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("
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("
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("
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("
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("
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("
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("
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("
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("
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("
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("
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("
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}