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_fmt(&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_fmt(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.into_owned(),
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_fmt(&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_fmt(&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("bar", false, Casing::Sensitive)],
950 })]);
951
952 let cell_path: Vec<CellPath> = center_columns
953 .into_list()
954 .unwrap()
955 .into_iter()
956 .map(|v| v.into_cell_path().unwrap())
957 .collect();
958
959 let center: Option<Vec<CellPath>> = Some(cell_path);
960
961 assert_eq!(
963 table(
964 value.clone().into_pipeline_data(),
965 true,
966 ¢er,
967 false,
968 false,
969 &Config::default()
970 ),
971 one("
972 | foo | bar |
973 | --- |:---:|
974 | 1 | 2 |
975 | 3 | 4 |
976 | 5 | 6 |
977 ")
978 );
979
980 assert_eq!(
982 table(
983 value.clone().into_pipeline_data(),
984 false,
985 ¢er,
986 false,
987 false,
988 &Config::default()
989 ),
990 one("
991 | foo | bar |
992 | --- |:---:|
993 | 1 | 2 |
994 | 3 | 4 |
995 | 5 | 6 |
996 ")
997 );
998 }
999
1000 #[test]
1001 fn test_empty_center_column() {
1002 let value = Value::test_list(vec![
1003 Value::test_record(record! {
1004 "foo" => Value::test_string("1"),
1005 "bar" => Value::test_string("2"),
1006 }),
1007 Value::test_record(record! {
1008 "foo" => Value::test_string("3"),
1009 "bar" => Value::test_string("4"),
1010 }),
1011 Value::test_record(record! {
1012 "foo" => Value::test_string("5"),
1013 "bar" => Value::test_string("6"),
1014 }),
1015 ]);
1016
1017 let center: Option<Vec<CellPath>> = Some(vec![]);
1018
1019 assert_eq!(
1020 table(
1021 value.clone().into_pipeline_data(),
1022 true,
1023 ¢er,
1024 false,
1025 false,
1026 &Config::default()
1027 ),
1028 one("
1029 | foo | bar |
1030 | --- | --- |
1031 | 1 | 2 |
1032 | 3 | 4 |
1033 | 5 | 6 |
1034 ")
1035 );
1036 }
1037
1038 #[test]
1039 fn test_center_multiple_columns() {
1040 let value = Value::test_list(vec![
1041 Value::test_record(record! {
1042 "command" => Value::test_string("ls"),
1043 "input" => Value::test_string("."),
1044 "output" => Value::test_string("file.txt"),
1045 }),
1046 Value::test_record(record! {
1047 "command" => Value::test_string("echo"),
1048 "input" => Value::test_string("'hi'"),
1049 "output" => Value::test_string("hi"),
1050 }),
1051 Value::test_record(record! {
1052 "command" => Value::test_string("cp"),
1053 "input" => Value::test_string("a.txt"),
1054 "output" => Value::test_string("b.txt"),
1055 }),
1056 ]);
1057
1058 let center_columns = Value::test_list(vec![
1059 Value::test_cell_path(CellPath {
1060 members: vec![PathMember::test_string("command", false, Casing::Sensitive)],
1061 }),
1062 Value::test_cell_path(CellPath {
1063 members: vec![PathMember::test_string("output", false, Casing::Sensitive)],
1064 }),
1065 ]);
1066
1067 let cell_path: Vec<CellPath> = center_columns
1068 .into_list()
1069 .unwrap()
1070 .into_iter()
1071 .map(|v| v.into_cell_path().unwrap())
1072 .collect();
1073
1074 let center: Option<Vec<CellPath>> = Some(cell_path);
1075
1076 assert_eq!(
1077 table(
1078 value.clone().into_pipeline_data(),
1079 true,
1080 ¢er,
1081 false,
1082 false,
1083 &Config::default()
1084 ),
1085 one("
1086 | command | input | output |
1087 |:-------:| ----- |:--------:|
1088 | ls | . | file.txt |
1089 | echo | 'hi' | hi |
1090 | cp | a.txt | b.txt |
1091 ")
1092 );
1093 }
1094
1095 #[test]
1096 fn test_center_non_existing_column() {
1097 let value = Value::test_list(vec![
1098 Value::test_record(record! {
1099 "name" => Value::test_string("Alice"),
1100 "age" => Value::test_string("30"),
1101 }),
1102 Value::test_record(record! {
1103 "name" => Value::test_string("Bob"),
1104 "age" => Value::test_string("5"),
1105 }),
1106 Value::test_record(record! {
1107 "name" => Value::test_string("Charlie"),
1108 "age" => Value::test_string("20"),
1109 }),
1110 ]);
1111
1112 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1113 members: vec![PathMember::test_string("none", false, Casing::Sensitive)],
1114 })]);
1115
1116 let cell_path: Vec<CellPath> = center_columns
1117 .into_list()
1118 .unwrap()
1119 .into_iter()
1120 .map(|v| v.into_cell_path().unwrap())
1121 .collect();
1122
1123 let center: Option<Vec<CellPath>> = Some(cell_path);
1124
1125 assert_eq!(
1126 table(
1127 value.clone().into_pipeline_data(),
1128 true,
1129 ¢er,
1130 false,
1131 false,
1132 &Config::default()
1133 ),
1134 one("
1135 | name | age |
1136 | ------- | --- |
1137 | Alice | 30 |
1138 | Bob | 5 |
1139 | Charlie | 20 |
1140 ")
1141 );
1142 }
1143
1144 #[test]
1145 fn test_center_complex_cell_path() {
1146 let value = Value::test_list(vec![
1147 Value::test_record(record! {
1148 "k" => Value::test_string("version"),
1149 "v" => Value::test_string("0.104.1"),
1150 }),
1151 Value::test_record(record! {
1152 "k" => Value::test_string("build_time"),
1153 "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1154 }),
1155 ]);
1156
1157 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1158 members: vec![
1159 PathMember::test_int(1, false),
1160 PathMember::test_string("v", false, Casing::Sensitive),
1161 ],
1162 })]);
1163
1164 let cell_path: Vec<CellPath> = center_columns
1165 .into_list()
1166 .unwrap()
1167 .into_iter()
1168 .map(|v| v.into_cell_path().unwrap())
1169 .collect();
1170
1171 let center: Option<Vec<CellPath>> = Some(cell_path);
1172
1173 assert_eq!(
1174 table(
1175 value.clone().into_pipeline_data(),
1176 true,
1177 ¢er,
1178 false,
1179 false,
1180 &Config::default()
1181 ),
1182 one("
1183 | k | v |
1184 | ---------- |:--------------------------:|
1185 | version | 0.104.1 |
1186 | build_time | 2025-05-28 11:00:45 +01:00 |
1187 ")
1188 );
1189 }
1190
1191 #[test]
1192 fn test_content_type_metadata() {
1193 let mut engine_state = Box::new(EngineState::new());
1194 let state_delta = {
1195 let mut working_set = StateWorkingSet::new(&engine_state);
1198
1199 working_set.add_decl(Box::new(ToMd {}));
1200 working_set.add_decl(Box::new(Metadata {}));
1201 working_set.add_decl(Box::new(Get {}));
1202
1203 working_set.render()
1204 };
1205 let delta = state_delta;
1206
1207 engine_state
1208 .merge_delta(delta)
1209 .expect("Error merging delta");
1210
1211 let cmd = "{a: 1 b: 2} | to md | metadata | get content_type | $in";
1212 let result = eval_pipeline_without_terminal_expression(
1213 cmd,
1214 std::env::temp_dir().as_ref(),
1215 &mut engine_state,
1216 );
1217 assert_eq!(
1218 Value::test_string("text/markdown"),
1219 result.expect("There should be a result")
1220 );
1221 }
1222
1223 #[test]
1224 fn test_escape_md_characters() {
1225 let value = Value::test_list(vec![
1226 Value::test_record(record! {
1227 "name|label" => Value::test_string("orderColumns"),
1228 "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1229 }),
1230 Value::test_record(record! {
1231 "name|label" => Value::test_string("_ref_value"),
1232 "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1233 }),
1234 Value::test_record(record! {
1235 "name|label" => Value::test_string("onChange"),
1236 "type*" => Value::test_string("(val: string) => void\\"),
1237 }),
1238 ]);
1239
1240 assert_eq!(
1241 table(
1242 value.clone().into_pipeline_data(),
1243 false,
1244 &None,
1245 false,
1246 false,
1247 &Config::default()
1248 ),
1249 one(r#"
1250 | name\|label | type* |
1251 | --- | --- |
1252 | orderColumns | 'asc' \| 'desc' \| 'none' |
1253 | _ref_value | RefObject<SampleTableRef \| null> |
1254 | onChange | (val: string) => void\\ |
1255 "#)
1256 );
1257
1258 assert_eq!(
1259 table(
1260 value.clone().into_pipeline_data(),
1261 false,
1262 &None,
1263 true,
1264 false,
1265 &Config::default()
1266 ),
1267 one(r#"
1268 | name\|label | type\* |
1269 | --- | --- |
1270 | orderColumns | 'asc' \| 'desc' \| 'none' |
1271 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1272 | onChange | \(val: string\) =\> void\\ |
1273 "#)
1274 );
1275
1276 assert_eq!(
1277 table(
1278 value.clone().into_pipeline_data(),
1279 true,
1280 &None,
1281 false,
1282 false,
1283 &Config::default()
1284 ),
1285 one(r#"
1286 | name\|label | type* |
1287 | ------------ | --------------------------------- |
1288 | orderColumns | 'asc' \| 'desc' \| 'none' |
1289 | _ref_value | RefObject<SampleTableRef \| null> |
1290 | onChange | (val: string) => void\\ |
1291 "#)
1292 );
1293
1294 assert_eq!(
1295 table(
1296 value.into_pipeline_data(),
1297 true,
1298 &None,
1299 true,
1300 false,
1301 &Config::default()
1302 ),
1303 one(r#"
1304 | name\|label | type\* |
1305 | ------------ | ----------------------------------- |
1306 | orderColumns | 'asc' \| 'desc' \| 'none' |
1307 | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1308 | onChange | \(val: string\) =\> void\\ |
1309 "#)
1310 );
1311 }
1312
1313 #[test]
1314 fn test_escape_html_characters() {
1315 let value = Value::test_list(vec![Value::test_record(record! {
1316 "tag" => Value::test_string("table"),
1317 "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>"#),
1318 })]);
1319
1320 assert_eq!(
1321 table(
1322 value.clone().into_pipeline_data(),
1323 false,
1324 &None,
1325 false,
1326 true,
1327 &Config::default()
1328 ),
1329 one("
1330 | tag | code |
1331 | --- | --- |
1332 | 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> |
1333 ")
1334 );
1335
1336 assert_eq!(
1337 table(
1338 value.into_pipeline_data(),
1339 true,
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
1353 #[test]
1354 fn test_list_ordered() {
1355 let value = Value::test_list(vec![
1356 Value::test_string("one"),
1357 Value::test_string("two"),
1358 Value::test_string("three"),
1359 ]);
1360
1361 let result = to_md(
1362 value.into_pipeline_data(),
1363 ToMdOptions {
1364 pretty: false,
1365 per_element: false,
1366 center: None,
1367 escape_md: false,
1368 escape_html: false,
1369 list_style: ListStyle::Ordered,
1370 },
1371 &Config::default(),
1372 Span::test_data(),
1373 )
1374 .unwrap()
1375 .into_value(Span::test_data())
1376 .unwrap()
1377 .into_string()
1378 .unwrap();
1379
1380 assert_eq!(result, "1. one\n2. two\n3. three");
1381 }
1382
1383 #[test]
1384 fn test_list_unordered() {
1385 let value = Value::test_list(vec![
1386 Value::test_string("apple"),
1387 Value::test_string("banana"),
1388 Value::test_string("cherry"),
1389 ]);
1390
1391 let result = to_md(
1392 value.into_pipeline_data(),
1393 ToMdOptions {
1394 pretty: false,
1395 per_element: false,
1396 center: None,
1397 escape_md: false,
1398 escape_html: false,
1399 list_style: ListStyle::Unordered,
1400 },
1401 &Config::default(),
1402 Span::test_data(),
1403 )
1404 .unwrap()
1405 .into_value(Span::test_data())
1406 .unwrap()
1407 .into_string()
1408 .unwrap();
1409
1410 assert_eq!(result, "* apple\n* banana\n* cherry");
1411 }
1412
1413 #[test]
1414 fn test_list_with_escape_md() {
1415 let value = Value::test_list(vec![
1416 Value::test_string("*bold*"),
1417 Value::test_string("[link]"),
1418 ]);
1419
1420 let result = to_md(
1421 value.into_pipeline_data(),
1422 ToMdOptions {
1423 pretty: false,
1424 per_element: false,
1425 center: None,
1426 escape_md: true,
1427 escape_html: false,
1428 list_style: ListStyle::Unordered,
1429 },
1430 &Config::default(),
1431 Span::test_data(),
1432 )
1433 .unwrap()
1434 .into_value(Span::test_data())
1435 .unwrap()
1436 .into_string()
1437 .unwrap();
1438
1439 assert_eq!(result, "* \\*bold\\*\n* \\[link\\]");
1440 }
1441
1442 #[test]
1443 fn test_list_none() {
1444 let value = Value::test_list(vec![
1445 Value::test_string("one"),
1446 Value::test_string("two"),
1447 Value::test_string("three"),
1448 ]);
1449
1450 let result = to_md(
1451 value.into_pipeline_data(),
1452 ToMdOptions {
1453 pretty: false,
1454 per_element: false,
1455 center: None,
1456 escape_md: false,
1457 escape_html: false,
1458 list_style: ListStyle::None,
1459 },
1460 &Config::default(),
1461 Span::test_data(),
1462 )
1463 .unwrap()
1464 .into_value(Span::test_data())
1465 .unwrap()
1466 .into_string()
1467 .unwrap();
1468
1469 assert_eq!(result, "one\ntwo\nthree");
1470 }
1471
1472 #[test]
1473 fn test_empty_list() {
1474 let value = Value::test_list(vec![]);
1475
1476 let result = to_md(
1477 value.into_pipeline_data(),
1478 ToMdOptions {
1479 pretty: false,
1480 per_element: false,
1481 center: None,
1482 escape_md: false,
1483 escape_html: false,
1484 list_style: ListStyle::Unordered,
1485 },
1486 &Config::default(),
1487 Span::test_data(),
1488 )
1489 .unwrap()
1490 .into_value(Span::test_data())
1491 .unwrap()
1492 .into_string()
1493 .unwrap();
1494
1495 assert_eq!(result, "");
1496 }
1497
1498 #[test]
1499 fn test_mixed_input_ordered() {
1500 let value = Value::test_list(vec![
1502 Value::test_record(record! {
1503 "h1" => Value::test_string("Title"),
1504 }),
1505 Value::test_string("first"),
1506 Value::test_string("second"),
1507 ]);
1508
1509 let result = to_md(
1510 value.into_pipeline_data(),
1511 ToMdOptions {
1512 pretty: false,
1513 per_element: true,
1514 center: None,
1515 escape_md: false,
1516 escape_html: false,
1517 list_style: ListStyle::Ordered,
1518 },
1519 &Config::default(),
1520 Span::test_data(),
1521 )
1522 .unwrap()
1523 .into_value(Span::test_data())
1524 .unwrap()
1525 .into_string()
1526 .unwrap();
1527
1528 assert_eq!(result, "# Title\n1. first\n2. second");
1530 }
1531}