nu_command/formats/to/
md.rs

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 | &lt;p&gt;Welcome to nushell&lt;&#x2f;p&gt; |",
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    // text/markdown became a valid mimetype with rfc7763
159    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        // With pretty
826        assert_eq!(
827            table(
828                value.clone().into_pipeline_data(),
829                true,
830                &center,
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        // Without pretty
845        assert_eq!(
846            table(
847                value.clone().into_pipeline_data(),
848                false,
849                &center,
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                &center,
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                &center,
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                &center,
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                &center,
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            // Base functions that are needed for testing
1072            // Try to keep this working set small to keep tests running as fast as possible
1073            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 | &lt;table&gt;&lt;tr&gt;&lt;td scope=&quot;row&quot;&gt;Chris&lt;&#x2f;td&gt;&lt;td&gt;HTML tables&lt;&#x2f;td&gt;&lt;td&gt;22&lt;&#x2f;td&gt;&lt;&#x2f;tr&gt;&lt;tr&gt;&lt;td scope=&quot;row&quot;&gt;Dennis&lt;&#x2f;td&gt;&lt;td&gt;Web accessibility&lt;&#x2f;td&gt;&lt;td&gt;45&lt;&#x2f;td&gt;&lt;&#x2f;tr&gt;&lt;&#x2f;table&gt; |
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 | &lt;table&gt;&lt;tr&gt;&lt;td scope=&quot;row&quot;&gt;Chris&lt;&#x2f;td&gt;&lt;td&gt;HTML tables&lt;&#x2f;td&gt;&lt;td&gt;22&lt;&#x2f;td&gt;&lt;&#x2f;tr&gt;&lt;tr&gt;&lt;td scope=&quot;row&quot;&gt;Dennis&lt;&#x2f;td&gt;&lt;td&gt;Web accessibility&lt;&#x2f;td&gt;&lt;td&gt;45&lt;&#x2f;td&gt;&lt;&#x2f;tr&gt;&lt;&#x2f;table&gt; |
1225            "#)
1226        );
1227    }
1228}