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
10struct ToMdOptions {
11 pretty: bool,
12 per_element: bool,
13 center: Option<Vec<CellPath>>,
14 escape_md: bool,
15 escape_html: bool,
16}
17
18impl Command for ToMd {
19 fn name(&self) -> &str {
20 "to md"
21 }
22
23 fn signature(&self) -> Signature {
24 Signature::build("to md")
25 .input_output_types(vec![(Type::Any, Type::String)])
26 .switch(
27 "pretty",
28 "Formats the Markdown table to vertically align items",
29 Some('p'),
30 )
31 .switch(
32 "per-element",
33 "Treat each row as markdown syntax element",
34 Some('e'),
35 )
36 .named(
37 "center",
38 SyntaxShape::List(Box::new(SyntaxShape::CellPath)),
39 "Formats the Markdown table to center given columns",
40 Some('c'),
41 )
42 .switch(
43 "escape-md",
44 "Escapes Markdown special characters",
45 Some('m'),
46 )
47 .switch("escape-html", "Escapes HTML special characters", Some('t'))
48 .switch(
49 "escape-all",
50 "Escapes both Markdown and HTML special characters",
51 Some('a'),
52 )
53 .category(Category::Formats)
54 }
55
56 fn description(&self) -> &str {
57 "Convert table into simple Markdown."
58 }
59
60 fn examples(&self) -> Vec<Example<'_>> {
61 vec![
62 Example {
63 description: "Outputs an MD string representing the contents of this table",
64 example: "[[foo bar]; [1 2]] | to md",
65 result: Some(Value::test_string(
66 "| foo | bar |\n| --- | --- |\n| 1 | 2 |",
67 )),
68 },
69 Example {
70 description: "Optionally, output a formatted markdown string",
71 example: "[[foo bar]; [1 2]] | to md --pretty",
72 result: Some(Value::test_string(
73 "| foo | bar |\n| --- | --- |\n| 1 | 2 |",
74 )),
75 },
76 Example {
77 description: "Treat each row as a markdown element",
78 example: r#"[{"H1": "Welcome to Nushell" } [[foo bar]; [1 2]]] | to md --per-element --pretty"#,
79 result: Some(Value::test_string(
80 "# Welcome to Nushell\n| foo | bar |\n| --- | --- |\n| 1 | 2 |",
81 )),
82 },
83 Example {
84 description: "Render a list",
85 example: "[0 1 2] | to md --pretty",
86 result: Some(Value::test_string("0\n1\n2")),
87 },
88 Example {
89 description: "Separate list into markdown tables",
90 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4} {foo: 5}] | to md --per-element",
91 result: Some(Value::test_string(
92 "| foo | bar |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n\n| foo |\n| --- |\n| 5 |",
93 )),
94 },
95 Example {
96 description: "Center a column of a markdown table",
97 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4}] | to md --pretty --center [bar]",
98 result: Some(Value::test_string(
99 "| foo | bar |\n| --- |:---:|\n| 1 | 2 |\n| 3 | 4 |",
100 )),
101 },
102 Example {
103 description: "Escape markdown special characters",
104 example: r#"[ {foo: "_1_", bar: "\# 2"} {foo: "[3]", bar: "4|5"}] | to md --escape-md"#,
105 result: Some(Value::test_string(
106 "| foo | bar |\n| --- | --- |\n| \\_1\\_ | \\# 2 |\n| \\[3\\] | 4\\|5 |",
107 )),
108 },
109 Example {
110 description: "Escape html special characters",
111 example: r#"[ {a: p, b: "<p>Welcome to nushell</p>"}] | to md --escape-html"#,
112 result: Some(Value::test_string(
113 "| a | b |\n| --- | --- |\n| p | <p>Welcome to nushell</p> |",
114 )),
115 },
116 ]
117 }
118
119 fn run(
120 &self,
121 engine_state: &EngineState,
122 stack: &mut Stack,
123 call: &Call,
124 input: PipelineData,
125 ) -> Result<PipelineData, ShellError> {
126 let head = call.head;
127
128 let pretty = call.has_flag(engine_state, stack, "pretty")?;
129 let per_element = call.has_flag(engine_state, stack, "per-element")?;
130 let escape_md = call.has_flag(engine_state, stack, "escape-md")?;
131 let escape_html = call.has_flag(engine_state, stack, "escape-html")?;
132 let escape_both = call.has_flag(engine_state, stack, "escape-all")?;
133 let center: Option<Vec<CellPath>> = call.get_flag(engine_state, stack, "center")?;
134
135 let config = stack.get_config(engine_state);
136
137 to_md(
138 input,
139 ToMdOptions {
140 pretty,
141 per_element,
142 center,
143 escape_md: escape_md || escape_both,
144 escape_html: escape_html || escape_both,
145 },
146 &config,
147 head,
148 )
149 }
150}
151
152fn to_md(
153 input: PipelineData,
154 options: ToMdOptions,
155 config: &Config,
156 head: Span,
157) -> Result<PipelineData, ShellError> {
158 let metadata = input
160 .metadata()
161 .unwrap_or_default()
162 .with_content_type(Some("text/markdown".into()));
163
164 let (grouped_input, single_list) = group_by(input, head, config);
165 if options.per_element || single_list {
166 return Ok(Value::string(
167 grouped_input
168 .into_iter()
169 .map(move |val| match val {
170 Value::List { .. } => {
171 format!(
172 "{}\n\n",
173 table(
174 val.into_pipeline_data(),
175 options.pretty,
176 &options.center,
177 options.escape_md,
178 options.escape_html,
179 config
180 )
181 )
182 }
183 other => fragment(
184 other,
185 options.pretty,
186 &options.center,
187 options.escape_md,
188 options.escape_html,
189 config,
190 ),
191 })
192 .collect::<Vec<String>>()
193 .join("")
194 .trim(),
195 head,
196 )
197 .into_pipeline_data_with_metadata(Some(metadata)));
198 }
199 Ok(Value::string(
200 table(
201 grouped_input,
202 options.pretty,
203 &options.center,
204 options.escape_md,
205 options.escape_html,
206 config,
207 ),
208 head,
209 )
210 .into_pipeline_data_with_metadata(Some(metadata)))
211}
212
213fn escape_markdown_characters(input: String, escape_md: bool, for_table: bool) -> String {
214 let mut output = String::with_capacity(input.len());
215 for ch in input.chars() {
216 let must_escape = match ch {
217 '\\' => true,
218 '|' if for_table => true,
219 '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '<' | '>' | '#' | '+' | '-'
220 | '.' | '!'
221 if escape_md =>
222 {
223 true
224 }
225 _ => false,
226 };
227
228 if must_escape {
229 output.push('\\');
230 }
231 output.push(ch);
232 }
233 output
234}
235
236fn fragment(
237 input: Value,
238 pretty: bool,
239 center: &Option<Vec<CellPath>>,
240 escape_md: bool,
241 escape_html: bool,
242 config: &Config,
243) -> String {
244 let mut out = String::new();
245
246 if let Value::Record { val, .. } = &input {
247 match val.get_index(0) {
248 Some((header, data)) if val.len() == 1 => {
249 let markup = match header.to_ascii_lowercase().as_ref() {
250 "h1" => "# ".to_string(),
251 "h2" => "## ".to_string(),
252 "h3" => "### ".to_string(),
253 "blockquote" => "> ".to_string(),
254 _ => {
255 return table(
256 input.into_pipeline_data(),
257 pretty,
258 center,
259 escape_md,
260 escape_html,
261 config,
262 );
263 }
264 };
265
266 let value_string = data.to_expanded_string("|", config);
267 out.push_str(&markup);
268 out.push_str(&escape_markdown_characters(
269 if escape_html {
270 v_htmlescape::escape(&value_string).to_string()
271 } else {
272 value_string
273 },
274 escape_md,
275 false,
276 ));
277 }
278 _ => {
279 out = table(
280 input.into_pipeline_data(),
281 pretty,
282 center,
283 escape_md,
284 escape_html,
285 config,
286 )
287 }
288 }
289 } else {
290 let value_string = input.to_expanded_string("|", config);
291 out = escape_markdown_characters(
292 if escape_html {
293 v_htmlescape::escape(&value_string).to_string()
294 } else {
295 value_string
296 },
297 escape_md,
298 false,
299 );
300 }
301
302 out.push('\n');
303 out
304}
305
306fn collect_headers(headers: &[String], escape_md: bool) -> (Vec<String>, Vec<usize>) {
307 let mut escaped_headers: Vec<String> = Vec::new();
308 let mut column_widths: Vec<usize> = Vec::new();
309
310 if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
311 for header in headers {
312 let escaped_header_string = escape_markdown_characters(
313 v_htmlescape::escape(header).to_string(),
314 escape_md,
315 true,
316 );
317 column_widths.push(escaped_header_string.len());
318 escaped_headers.push(escaped_header_string);
319 }
320 } else {
321 column_widths = vec![0; headers.len()];
322 }
323
324 (escaped_headers, column_widths)
325}
326
327fn table(
328 input: PipelineData,
329 pretty: bool,
330 center: &Option<Vec<CellPath>>,
331 escape_md: bool,
332 escape_html: bool,
333 config: &Config,
334) -> String {
335 let vec_of_values = input
336 .into_iter()
337 .flat_map(|val| match val {
338 Value::List { vals, .. } => vals,
339 other => vec![other],
340 })
341 .collect::<Vec<Value>>();
342 let mut headers = merge_descriptors(&vec_of_values);
343
344 let mut empty_header_index = 0;
345 for value in &vec_of_values {
346 if let Value::Record { val, .. } = value {
347 for column in val.columns() {
348 if column.is_empty() && !headers.contains(&String::new()) {
349 headers.insert(empty_header_index, String::new());
350 empty_header_index += 1;
351 break;
352 }
353 empty_header_index += 1;
354 }
355 }
356 }
357
358 let (escaped_headers, mut column_widths) = collect_headers(&headers, escape_md);
359
360 let mut escaped_rows: Vec<Vec<String>> = Vec::new();
361
362 for row in vec_of_values {
363 let mut escaped_row: Vec<String> = Vec::new();
364 let span = row.span();
365
366 match row.to_owned() {
367 Value::Record { val: row, .. } => {
368 for i in 0..headers.len() {
369 let value_string = row
370 .get(&headers[i])
371 .cloned()
372 .unwrap_or_else(|| Value::nothing(span))
373 .to_expanded_string(", ", config);
374 let escaped_string = escape_markdown_characters(
375 if escape_html {
376 v_htmlescape::escape(&value_string).to_string()
377 } else {
378 value_string
379 },
380 escape_md,
381 true,
382 );
383
384 let new_column_width = escaped_string.len();
385 escaped_row.push(escaped_string);
386
387 if column_widths[i] < new_column_width {
388 column_widths[i] = new_column_width;
389 }
390 if column_widths[i] < 3 {
391 column_widths[i] = 3;
392 }
393 }
394 }
395 p => {
396 let value_string =
397 v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
398 escaped_row.push(value_string);
399 }
400 }
401
402 escaped_rows.push(escaped_row);
403 }
404
405 if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
406 && escaped_rows.is_empty()
407 {
408 String::from("")
409 } else {
410 get_output_string(
411 &escaped_headers,
412 &escaped_rows,
413 &column_widths,
414 pretty,
415 center,
416 )
417 .trim()
418 .to_string()
419 }
420}
421
422pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
423 let mut lists = IndexMap::new();
424 let mut single_list = false;
425 for val in values {
426 if let Value::Record {
427 val: ref record, ..
428 } = val
429 {
430 lists
431 .entry(record.columns().map(|c| c.as_str()).collect::<String>())
432 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
433 .or_insert_with(|| vec![val.clone()]);
434 } else {
435 lists
436 .entry(val.to_expanded_string(",", config))
437 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
438 .or_insert_with(|| vec![val.clone()]);
439 }
440 }
441 let mut output = vec![];
442 for (_, mut value) in lists {
443 if value.len() == 1 {
444 output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
445 } else {
446 output.push(Value::list(value.to_vec(), head))
447 }
448 }
449 if output.len() == 1 {
450 single_list = true;
451 }
452 (Value::list(output, head).into_pipeline_data(), single_list)
453}
454
455fn get_output_string(
456 headers: &[String],
457 rows: &[Vec<String>],
458 column_widths: &[usize],
459 pretty: bool,
460 center: &Option<Vec<CellPath>>,
461) -> String {
462 let mut output_string = String::new();
463
464 let mut to_center: HashSet<String> = HashSet::new();
465 if let Some(center_vec) = center.as_ref() {
466 for cell_path in center_vec {
467 if let Some(PathMember::String { val, .. }) = cell_path
468 .members
469 .iter()
470 .find(|member| matches!(member, PathMember::String { .. }))
471 {
472 to_center.insert(val.clone());
473 }
474 }
475 }
476
477 if !headers.is_empty() {
478 output_string.push('|');
479
480 for i in 0..headers.len() {
481 output_string.push(' ');
482 if pretty {
483 if center.is_some() && to_center.contains(&headers[i]) {
484 output_string.push_str(&get_centered_string(
485 headers[i].clone(),
486 column_widths[i],
487 ' ',
488 ));
489 } else {
490 output_string.push_str(&get_padded_string(
491 headers[i].clone(),
492 column_widths[i],
493 ' ',
494 ));
495 }
496 } else {
497 output_string.push_str(&headers[i]);
498 }
499
500 output_string.push_str(" |");
501 }
502
503 output_string.push_str("\n|");
504
505 for i in 0..headers.len() {
506 let centered_column = center.is_some() && to_center.contains(&headers[i]);
507 let border_char = if centered_column { ':' } else { ' ' };
508 if pretty {
509 output_string.push(border_char);
510 output_string.push_str(&get_padded_string(
511 String::from("-"),
512 column_widths[i],
513 '-',
514 ));
515 output_string.push(border_char);
516 } else if centered_column {
517 output_string.push_str(":---:");
518 } else {
519 output_string.push_str(" --- ");
520 }
521
522 output_string.push('|');
523 }
524
525 output_string.push('\n');
526 }
527
528 for row in rows {
529 if !headers.is_empty() {
530 output_string.push('|');
531 }
532
533 for i in 0..row.len() {
534 if !headers.is_empty() {
535 output_string.push(' ');
536 }
537
538 if pretty && column_widths.get(i).is_some() {
539 if center.is_some() && to_center.contains(&headers[i]) {
540 output_string.push_str(&get_centered_string(
541 row[i].clone(),
542 column_widths[i],
543 ' ',
544 ));
545 } else {
546 output_string.push_str(&get_padded_string(
547 row[i].clone(),
548 column_widths[i],
549 ' ',
550 ));
551 }
552 } else {
553 output_string.push_str(&row[i]);
554 }
555
556 if !headers.is_empty() {
557 output_string.push_str(" |");
558 }
559 }
560
561 output_string.push('\n');
562 }
563
564 output_string
565}
566
567fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
568 let total_padding = if text.len() > desired_length {
569 0
570 } else {
571 desired_length - text.len()
572 };
573
574 let repeat_left = total_padding / 2;
575 let repeat_right = total_padding - repeat_left;
576
577 format!(
578 "{}{}{}",
579 padding_character.to_string().repeat(repeat_left),
580 text,
581 padding_character.to_string().repeat(repeat_right)
582 )
583}
584
585fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
586 let repeat_length = if text.len() > desired_length {
587 0
588 } else {
589 desired_length - text.len()
590 };
591
592 format!(
593 "{}{}",
594 text,
595 padding_character.to_string().repeat(repeat_length)
596 )
597}
598
599#[cfg(test)]
600mod tests {
601 use crate::{Get, Metadata};
602
603 use super::*;
604 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
605 use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
606
607 fn one(string: &str) -> String {
608 string
609 .lines()
610 .skip(1)
611 .map(|line| line.trim())
612 .collect::<Vec<&str>>()
613 .join("\n")
614 .trim_end()
615 .to_string()
616 }
617
618 #[test]
619 fn test_examples() {
620 use crate::test_examples;
621
622 test_examples(ToMd {})
623 }
624
625 #[test]
626 fn render_h1() {
627 let value = Value::test_record(record! {
628 "H1" => Value::test_string("Ecuador"),
629 });
630
631 assert_eq!(
632 fragment(value, false, &None, false, false, &Config::default()),
633 "# Ecuador\n"
634 );
635 }
636
637 #[test]
638 fn render_h2() {
639 let value = Value::test_record(record! {
640 "H2" => Value::test_string("Ecuador"),
641 });
642
643 assert_eq!(
644 fragment(value, false, &None, false, false, &Config::default()),
645 "## Ecuador\n"
646 );
647 }
648
649 #[test]
650 fn render_h3() {
651 let value = Value::test_record(record! {
652 "H3" => Value::test_string("Ecuador"),
653 });
654
655 assert_eq!(
656 fragment(value, false, &None, false, false, &Config::default()),
657 "### Ecuador\n"
658 );
659 }
660
661 #[test]
662 fn render_blockquote() {
663 let value = Value::test_record(record! {
664 "BLOCKQUOTE" => Value::test_string("Ecuador"),
665 });
666
667 assert_eq!(
668 fragment(value, false, &None, false, false, &Config::default()),
669 "> Ecuador\n"
670 );
671 }
672
673 #[test]
674 fn render_table() {
675 let value = Value::test_list(vec![
676 Value::test_record(record! {
677 "country" => Value::test_string("Ecuador"),
678 }),
679 Value::test_record(record! {
680 "country" => Value::test_string("New Zealand"),
681 }),
682 Value::test_record(record! {
683 "country" => Value::test_string("USA"),
684 }),
685 ]);
686
687 assert_eq!(
688 table(
689 value.clone().into_pipeline_data(),
690 false,
691 &None,
692 false,
693 false,
694 &Config::default()
695 ),
696 one(r#"
697 | country |
698 | --- |
699 | Ecuador |
700 | New Zealand |
701 | USA |
702 "#)
703 );
704
705 assert_eq!(
706 table(
707 value.into_pipeline_data(),
708 true,
709 &None,
710 false,
711 false,
712 &Config::default()
713 ),
714 one(r#"
715 | country |
716 | ----------- |
717 | Ecuador |
718 | New Zealand |
719 | USA |
720 "#)
721 );
722 }
723
724 #[test]
725 fn test_empty_column_header() {
726 let value = Value::test_list(vec![
727 Value::test_record(record! {
728 "" => Value::test_string("1"),
729 "foo" => Value::test_string("2"),
730 }),
731 Value::test_record(record! {
732 "" => Value::test_string("3"),
733 "foo" => Value::test_string("4"),
734 }),
735 ]);
736
737 assert_eq!(
738 table(
739 value.clone().into_pipeline_data(),
740 false,
741 &None,
742 false,
743 false,
744 &Config::default()
745 ),
746 one(r#"
747 | | foo |
748 | --- | --- |
749 | 1 | 2 |
750 | 3 | 4 |
751 "#)
752 );
753 }
754
755 #[test]
756 fn test_empty_row_value() {
757 let value = Value::test_list(vec![
758 Value::test_record(record! {
759 "foo" => Value::test_string("1"),
760 "bar" => Value::test_string("2"),
761 }),
762 Value::test_record(record! {
763 "foo" => Value::test_string("3"),
764 "bar" => Value::test_string("4"),
765 }),
766 Value::test_record(record! {
767 "foo" => Value::test_string("5"),
768 "bar" => Value::test_string(""),
769 }),
770 ]);
771
772 assert_eq!(
773 table(
774 value.clone().into_pipeline_data(),
775 false,
776 &None,
777 false,
778 false,
779 &Config::default()
780 ),
781 one(r#"
782 | foo | bar |
783 | --- | --- |
784 | 1 | 2 |
785 | 3 | 4 |
786 | 5 | |
787 "#)
788 );
789 }
790
791 #[test]
792 fn test_center_column() {
793 let value = Value::test_list(vec![
794 Value::test_record(record! {
795 "foo" => Value::test_string("1"),
796 "bar" => Value::test_string("2"),
797 }),
798 Value::test_record(record! {
799 "foo" => Value::test_string("3"),
800 "bar" => Value::test_string("4"),
801 }),
802 Value::test_record(record! {
803 "foo" => Value::test_string("5"),
804 "bar" => Value::test_string("6"),
805 }),
806 ]);
807
808 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
809 members: vec![PathMember::test_string(
810 "bar".into(),
811 false,
812 Casing::Sensitive,
813 )],
814 })]);
815
816 let cell_path: Vec<CellPath> = center_columns
817 .into_list()
818 .unwrap()
819 .into_iter()
820 .map(|v| v.into_cell_path().unwrap())
821 .collect();
822
823 let center: Option<Vec<CellPath>> = Some(cell_path);
824
825 assert_eq!(
827 table(
828 value.clone().into_pipeline_data(),
829 true,
830 ¢er,
831 false,
832 false,
833 &Config::default()
834 ),
835 one(r#"
836 | foo | bar |
837 | --- |:---:|
838 | 1 | 2 |
839 | 3 | 4 |
840 | 5 | 6 |
841 "#)
842 );
843
844 assert_eq!(
846 table(
847 value.clone().into_pipeline_data(),
848 false,
849 ¢er,
850 false,
851 false,
852 &Config::default()
853 ),
854 one(r#"
855 | foo | bar |
856 | --- |:---:|
857 | 1 | 2 |
858 | 3 | 4 |
859 | 5 | 6 |
860 "#)
861 );
862 }
863
864 #[test]
865 fn test_empty_center_column() {
866 let value = Value::test_list(vec![
867 Value::test_record(record! {
868 "foo" => Value::test_string("1"),
869 "bar" => Value::test_string("2"),
870 }),
871 Value::test_record(record! {
872 "foo" => Value::test_string("3"),
873 "bar" => Value::test_string("4"),
874 }),
875 Value::test_record(record! {
876 "foo" => Value::test_string("5"),
877 "bar" => Value::test_string("6"),
878 }),
879 ]);
880
881 let center: Option<Vec<CellPath>> = Some(vec![]);
882
883 assert_eq!(
884 table(
885 value.clone().into_pipeline_data(),
886 true,
887 ¢er,
888 false,
889 false,
890 &Config::default()
891 ),
892 one(r#"
893 | foo | bar |
894 | --- | --- |
895 | 1 | 2 |
896 | 3 | 4 |
897 | 5 | 6 |
898 "#)
899 );
900 }
901
902 #[test]
903 fn test_center_multiple_columns() {
904 let value = Value::test_list(vec![
905 Value::test_record(record! {
906 "command" => Value::test_string("ls"),
907 "input" => Value::test_string("."),
908 "output" => Value::test_string("file.txt"),
909 }),
910 Value::test_record(record! {
911 "command" => Value::test_string("echo"),
912 "input" => Value::test_string("'hi'"),
913 "output" => Value::test_string("hi"),
914 }),
915 Value::test_record(record! {
916 "command" => Value::test_string("cp"),
917 "input" => Value::test_string("a.txt"),
918 "output" => Value::test_string("b.txt"),
919 }),
920 ]);
921
922 let center_columns = Value::test_list(vec![
923 Value::test_cell_path(CellPath {
924 members: vec![PathMember::test_string(
925 "command".into(),
926 false,
927 Casing::Sensitive,
928 )],
929 }),
930 Value::test_cell_path(CellPath {
931 members: vec![PathMember::test_string(
932 "output".into(),
933 false,
934 Casing::Sensitive,
935 )],
936 }),
937 ]);
938
939 let cell_path: Vec<CellPath> = center_columns
940 .into_list()
941 .unwrap()
942 .into_iter()
943 .map(|v| v.into_cell_path().unwrap())
944 .collect();
945
946 let center: Option<Vec<CellPath>> = Some(cell_path);
947
948 assert_eq!(
949 table(
950 value.clone().into_pipeline_data(),
951 true,
952 ¢er,
953 false,
954 false,
955 &Config::default()
956 ),
957 one(r#"
958 | command | input | output |
959 |:-------:| ----- |:--------:|
960 | ls | . | file.txt |
961 | echo | 'hi' | hi |
962 | cp | a.txt | b.txt |
963 "#)
964 );
965 }
966
967 #[test]
968 fn test_center_non_existing_column() {
969 let value = Value::test_list(vec![
970 Value::test_record(record! {
971 "name" => Value::test_string("Alice"),
972 "age" => Value::test_string("30"),
973 }),
974 Value::test_record(record! {
975 "name" => Value::test_string("Bob"),
976 "age" => Value::test_string("5"),
977 }),
978 Value::test_record(record! {
979 "name" => Value::test_string("Charlie"),
980 "age" => Value::test_string("20"),
981 }),
982 ]);
983
984 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
985 members: vec![PathMember::test_string(
986 "none".into(),
987 false,
988 Casing::Sensitive,
989 )],
990 })]);
991
992 let cell_path: Vec<CellPath> = center_columns
993 .into_list()
994 .unwrap()
995 .into_iter()
996 .map(|v| v.into_cell_path().unwrap())
997 .collect();
998
999 let center: Option<Vec<CellPath>> = Some(cell_path);
1000
1001 assert_eq!(
1002 table(
1003 value.clone().into_pipeline_data(),
1004 true,
1005 ¢er,
1006 false,
1007 false,
1008 &Config::default()
1009 ),
1010 one(r#"
1011 | name | age |
1012 | ------- | --- |
1013 | Alice | 30 |
1014 | Bob | 5 |
1015 | Charlie | 20 |
1016 "#)
1017 );
1018 }
1019
1020 #[test]
1021 fn test_center_complex_cell_path() {
1022 let value = Value::test_list(vec![
1023 Value::test_record(record! {
1024 "k" => Value::test_string("version"),
1025 "v" => Value::test_string("0.104.1"),
1026 }),
1027 Value::test_record(record! {
1028 "k" => Value::test_string("build_time"),
1029 "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1030 }),
1031 ]);
1032
1033 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1034 members: vec![
1035 PathMember::test_int(1, false),
1036 PathMember::test_string("v".into(), false, Casing::Sensitive),
1037 ],
1038 })]);
1039
1040 let cell_path: Vec<CellPath> = center_columns
1041 .into_list()
1042 .unwrap()
1043 .into_iter()
1044 .map(|v| v.into_cell_path().unwrap())
1045 .collect();
1046
1047 let center: Option<Vec<CellPath>> = Some(cell_path);
1048
1049 assert_eq!(
1050 table(
1051 value.clone().into_pipeline_data(),
1052 true,
1053 ¢er,
1054 false,
1055 false,
1056 &Config::default()
1057 ),
1058 one(r#"
1059 | k | v |
1060 | ---------- |:--------------------------:|
1061 | version | 0.104.1 |
1062 | build_time | 2025-05-28 11:00:45 +01:00 |
1063 "#)
1064 );
1065 }
1066
1067 #[test]
1068 fn test_content_type_metadata() {
1069 let mut engine_state = Box::new(EngineState::new());
1070 let state_delta = {
1071 let mut working_set = StateWorkingSet::new(&engine_state);
1074
1075 working_set.add_decl(Box::new(ToMd {}));
1076 working_set.add_decl(Box::new(Metadata {}));
1077 working_set.add_decl(Box::new(Get {}));
1078
1079 working_set.render()
1080 };
1081 let delta = state_delta;
1082
1083 engine_state
1084 .merge_delta(delta)
1085 .expect("Error merging delta");
1086
1087 let cmd = "{a: 1 b: 2} | to md | metadata | get content_type | $in";
1088 let result = eval_pipeline_without_terminal_expression(
1089 cmd,
1090 std::env::temp_dir().as_ref(),
1091 &mut engine_state,
1092 );
1093 assert_eq!(
1094 Value::test_string("text/markdown"),
1095 result.expect("There should be a result")
1096 );
1097 }
1098
1099 #[test]
1100 fn test_escape_md_characters() {
1101 let value = Value::test_list(vec![
1102 Value::test_record(record! {
1103 "name|label" => Value::test_string("orderColumns"),
1104 "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1105 }),
1106 Value::test_record(record! {
1107 "name|label" => Value::test_string("_ref_value"),
1108 "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1109 }),
1110 Value::test_record(record! {
1111 "name|label" => Value::test_string("onChange"),
1112 "type*" => Value::test_string("(val: string) => void\\"),
1113 }),
1114 ]);
1115
1116 assert_eq!(
1117 table(
1118 value.clone().into_pipeline_data(),
1119 false,
1120 &None,
1121 false,
1122 false,
1123 &Config::default()
1124 ),
1125 one(r#"
1126 | name\|label | type* |
1127 | --- | --- |
1128 | orderColumns | 'asc' \| 'desc' \| 'none' |
1129 | _ref_value | RefObject<SampleTableRef \| null> |
1130 | onChange | (val: string) => void\\ |
1131 "#)
1132 );
1133
1134 assert_eq!(
1135 table(
1136 value.clone().into_pipeline_data(),
1137 false,
1138 &None,
1139 true,
1140 false,
1141 &Config::default()
1142 ),
1143 one(r#"
1144 | name\|label | type\* |
1145 | --- | --- |
1146 | orderColumns | 'asc' \| 'desc' \| 'none' |
1147 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1148 | onChange | \(val: string\) =\> void\\ |
1149 "#)
1150 );
1151
1152 assert_eq!(
1153 table(
1154 value.clone().into_pipeline_data(),
1155 true,
1156 &None,
1157 false,
1158 false,
1159 &Config::default()
1160 ),
1161 one(r#"
1162 | name\|label | type* |
1163 | ------------ | --------------------------------- |
1164 | orderColumns | 'asc' \| 'desc' \| 'none' |
1165 | _ref_value | RefObject<SampleTableRef \| null> |
1166 | onChange | (val: string) => void\\ |
1167 "#)
1168 );
1169
1170 assert_eq!(
1171 table(
1172 value.into_pipeline_data(),
1173 true,
1174 &None,
1175 true,
1176 false,
1177 &Config::default()
1178 ),
1179 one(r#"
1180 | name\|label | type\* |
1181 | ------------ | ----------------------------------- |
1182 | orderColumns | 'asc' \| 'desc' \| 'none' |
1183 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1184 | onChange | \(val: string\) =\> void\\ |
1185 "#)
1186 );
1187 }
1188
1189 #[test]
1190 fn test_escape_html_characters() {
1191 let value = Value::test_list(vec![Value::test_record(record! {
1192 "tag" => Value::test_string("table"),
1193 "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>"#),
1194 })]);
1195
1196 assert_eq!(
1197 table(
1198 value.clone().into_pipeline_data(),
1199 false,
1200 &None,
1201 false,
1202 true,
1203 &Config::default()
1204 ),
1205 one(r#"
1206 | tag | code |
1207 | --- | --- |
1208 | 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> |
1209 "#)
1210 );
1211
1212 assert_eq!(
1213 table(
1214 value.into_pipeline_data(),
1215 true,
1216 &None,
1217 false,
1218 true,
1219 &Config::default()
1220 ),
1221 one(r#"
1222 | tag | code |
1223 | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1224 | 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> |
1225 "#)
1226 );
1227 }
1228}