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
10/// Defines how lists should be formatted in Markdown output
11#[derive(Clone, Copy, Default, PartialEq)]
12enum ListStyle {
13    /// No list markers, just plain text separated by newlines
14    None,
15    /// Ordered list using "1. ", "2. ", etc.
16    Ordered,
17    /// Unordered list using "* " (default)
18    #[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
44/// Special markdown element types that can be represented as single-field records
45const SPECIAL_MARKDOWN_HEADERS: &[&str] = &["h1", "h2", "h3", "blockquote"];
46
47/// Check if a record represents a special markdown element (h1, h2, h3, blockquote)
48fn 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 | &lt;p&gt;Welcome to nushell&lt;&#x2f;p&gt; |",
157                )),
158            },
159            Example {
160                description: "Render a list as an ordered markdown list",
161                example: "[one two three] | to md --list ordered",
162                result: Some(Value::test_string("1. one\n2. two\n3. three")),
163            },
164            Example {
165                description: "Render a list without markers",
166                example: "[one two three] | to md --list none",
167                result: Some(Value::test_string("one\ntwo\nthree")),
168            },
169        ]
170    }
171
172    fn run(
173        &self,
174        engine_state: &EngineState,
175        stack: &mut Stack,
176        call: &Call,
177        input: PipelineData,
178    ) -> Result<PipelineData, ShellError> {
179        let head = call.head;
180
181        let pretty = call.has_flag(engine_state, stack, "pretty")?;
182        let per_element = call.has_flag(engine_state, stack, "per-element")?;
183        let escape_md = call.has_flag(engine_state, stack, "escape-md")?;
184        let escape_html = call.has_flag(engine_state, stack, "escape-html")?;
185        let escape_both = call.has_flag(engine_state, stack, "escape-all")?;
186        let center: Option<Vec<CellPath>> = call.get_flag(engine_state, stack, "center")?;
187        let list_style_str: Option<Spanned<String>> = call.get_flag(engine_state, stack, "list")?;
188
189        let list_style = match &list_style_str {
190            Some(spanned) => {
191                ListStyle::from_str(&spanned.item).ok_or_else(|| ShellError::InvalidValue {
192                    valid: format!("one of {}", ListStyle::OPTIONS.join(", ")),
193                    actual: spanned.item.clone(),
194                    span: spanned.span,
195                })?
196            }
197            None => ListStyle::default(),
198        };
199
200        let config = stack.get_config(engine_state);
201
202        to_md(
203            input,
204            ToMdOptions {
205                pretty,
206                per_element,
207                center,
208                escape_md: escape_md || escape_both,
209                escape_html: escape_html || escape_both,
210                list_style,
211            },
212            &config,
213            head,
214        )
215    }
216}
217
218fn to_md(
219    input: PipelineData,
220    options: ToMdOptions,
221    config: &Config,
222    head: Span,
223) -> Result<PipelineData, ShellError> {
224    // text/markdown became a valid mimetype with rfc7763
225    let metadata = input
226        .metadata()
227        .unwrap_or_default()
228        .with_content_type(Some("text/markdown".into()));
229
230    // Collect input to check if it's a simple list (no records/tables)
231    let values: Vec<Value> = input.into_iter().collect();
232
233    // Check if input is a simple list (no records, lists, or tables)
234    // Tables in nushell can be represented as List of Records or List of Lists
235    let is_simple_list = !values
236        .iter()
237        .any(|v| matches!(v, Value::Record { .. } | Value::List { .. }));
238
239    // For simple lists, use list_style formatting
240    if is_simple_list {
241        let result = values
242            .into_iter()
243            .enumerate()
244            .map(|(idx, val)| {
245                format_list_item(
246                    val,
247                    idx,
248                    options.list_style,
249                    options.escape_md,
250                    options.escape_html,
251                    config,
252                )
253            })
254            .collect::<Vec<String>>()
255            .join("")
256            .trim()
257            .to_string();
258        return Ok(Value::string(result, head).into_pipeline_data_with_metadata(Some(metadata)));
259    }
260
261    // For tables/records, use the grouping logic
262    let input = Value::list(values, head).into_pipeline_data();
263    let (grouped_input, single_list) = group_by(input, head, config);
264    if options.per_element || single_list {
265        return Ok(Value::string(
266            grouped_input
267                .into_iter()
268                .scan(0usize, |list_idx, val| {
269                    Some(match &val {
270                        Value::List { .. } => {
271                            format!(
272                                "{}\n\n",
273                                table(
274                                    val.into_pipeline_data(),
275                                    options.pretty,
276                                    &options.center,
277                                    options.escape_md,
278                                    options.escape_html,
279                                    config
280                                )
281                            )
282                        }
283                        // For records, check if it's a special markdown element (h1, h2, etc.)
284                        Value::Record { val: record, .. } => {
285                            if is_special_markdown_record(record) {
286                                // Special markdown elements use fragment() directly
287                                fragment(
288                                    val,
289                                    options.pretty,
290                                    &options.center,
291                                    options.escape_md,
292                                    options.escape_html,
293                                    config,
294                                )
295                            } else {
296                                // Regular records are rendered as tables
297                                format!(
298                                    "{}\n\n",
299                                    fragment(
300                                        val,
301                                        options.pretty,
302                                        &options.center,
303                                        options.escape_md,
304                                        options.escape_html,
305                                        config
306                                    )
307                                )
308                            }
309                        }
310                        _ => {
311                            let result = format_list_item(
312                                val,
313                                *list_idx,
314                                options.list_style,
315                                options.escape_md,
316                                options.escape_html,
317                                config,
318                            );
319                            *list_idx += 1;
320                            result
321                        }
322                    })
323                })
324                .collect::<Vec<String>>()
325                .join("")
326                .trim(),
327            head,
328        )
329        .into_pipeline_data_with_metadata(Some(metadata)));
330    }
331    Ok(Value::string(
332        table(
333            grouped_input,
334            options.pretty,
335            &options.center,
336            options.escape_md,
337            options.escape_html,
338            config,
339        ),
340        head,
341    )
342    .into_pipeline_data_with_metadata(Some(metadata)))
343}
344
345/// Formats a single list item with the appropriate list marker based on list_style
346fn format_list_item(
347    input: Value,
348    index: usize,
349    list_style: ListStyle,
350    escape_md: bool,
351    escape_html: bool,
352    config: &Config,
353) -> String {
354    let value_string = input.to_expanded_string("|", config);
355    let escaped = escape_value(value_string, escape_md, escape_html, false);
356
357    match list_style {
358        ListStyle::Ordered => format!("{}. {}\n", index + 1, escaped),
359        ListStyle::Unordered => format!("* {}\n", escaped),
360        ListStyle::None => format!("{}\n", escaped),
361    }
362}
363
364fn escape_markdown_characters(input: String, escape_md: bool, for_table: bool) -> String {
365    let mut output = String::with_capacity(input.len());
366    for ch in input.chars() {
367        let must_escape = match ch {
368            '\\' => true,
369            '|' if for_table => true,
370            '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '<' | '>' | '#' | '+' | '-'
371            | '.' | '!'
372                if escape_md =>
373            {
374                true
375            }
376            _ => false,
377        };
378
379        if must_escape {
380            output.push('\\');
381        }
382        output.push(ch);
383    }
384    output
385}
386
387/// Escapes a value string with optional HTML and Markdown escaping
388fn escape_value(value: String, escape_md: bool, escape_html: bool, for_table: bool) -> String {
389    escape_markdown_characters(
390        if escape_html {
391            v_htmlescape::escape(&value).to_string()
392        } else {
393            value
394        },
395        escape_md,
396        for_table,
397    )
398}
399
400fn fragment(
401    input: Value,
402    pretty: bool,
403    center: &Option<Vec<CellPath>>,
404    escape_md: bool,
405    escape_html: bool,
406    config: &Config,
407) -> String {
408    let mut out = String::new();
409
410    if let Value::Record { val, .. } = &input {
411        match val.get_index(0) {
412            Some((header, data)) if is_special_markdown_record(val) => {
413                // SAFETY: is_special_markdown_record already validated the header matches one of these
414                let markup = match header.to_ascii_lowercase().as_ref() {
415                    "h1" => "# ",
416                    "h2" => "## ",
417                    "h3" => "### ",
418                    "blockquote" => "> ",
419                    _ => "> ", // Fallback for any future validated headers not yet listed explicitly
420                };
421
422                let value_string = data.to_expanded_string("|", config);
423                out.push_str(markup);
424                out.push_str(&escape_value(value_string, escape_md, escape_html, false));
425            }
426            _ => {
427                out = table(
428                    input.into_pipeline_data(),
429                    pretty,
430                    center,
431                    escape_md,
432                    escape_html,
433                    config,
434                )
435            }
436        }
437    } else {
438        let value_string = input.to_expanded_string("|", config);
439        out = escape_value(value_string, escape_md, escape_html, false);
440    }
441
442    out.push('\n');
443    out
444}
445
446fn collect_headers(headers: &[String], escape_md: bool) -> (Vec<String>, Vec<usize>) {
447    let mut escaped_headers: Vec<String> = Vec::new();
448    let mut column_widths: Vec<usize> = Vec::new();
449
450    if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
451        for header in headers {
452            let escaped_header_string = escape_markdown_characters(
453                v_htmlescape::escape(header).to_string(),
454                escape_md,
455                true,
456            );
457            column_widths.push(escaped_header_string.len());
458            escaped_headers.push(escaped_header_string);
459        }
460    } else {
461        column_widths = vec![0; headers.len()];
462    }
463
464    (escaped_headers, column_widths)
465}
466
467fn table(
468    input: PipelineData,
469    pretty: bool,
470    center: &Option<Vec<CellPath>>,
471    escape_md: bool,
472    escape_html: bool,
473    config: &Config,
474) -> String {
475    let vec_of_values = input
476        .into_iter()
477        .flat_map(|val| match val {
478            Value::List { vals, .. } => vals,
479            other => vec![other],
480        })
481        .collect::<Vec<Value>>();
482    let mut headers = merge_descriptors(&vec_of_values);
483
484    let mut empty_header_index = 0;
485    for value in &vec_of_values {
486        if let Value::Record { val, .. } = value {
487            for column in val.columns() {
488                if column.is_empty() && !headers.contains(&String::new()) {
489                    headers.insert(empty_header_index, String::new());
490                    empty_header_index += 1;
491                    break;
492                }
493                empty_header_index += 1;
494            }
495        }
496    }
497
498    let (escaped_headers, mut column_widths) = collect_headers(&headers, escape_md);
499
500    let mut escaped_rows: Vec<Vec<String>> = Vec::new();
501
502    for row in vec_of_values {
503        let mut escaped_row: Vec<String> = Vec::new();
504        let span = row.span();
505
506        match row.to_owned() {
507            Value::Record { val: row, .. } => {
508                for i in 0..headers.len() {
509                    let value_string = row
510                        .get(&headers[i])
511                        .cloned()
512                        .unwrap_or_else(|| Value::nothing(span))
513                        .to_expanded_string(", ", config);
514                    let escaped_string = escape_markdown_characters(
515                        if escape_html {
516                            v_htmlescape::escape(&value_string).to_string()
517                        } else {
518                            value_string
519                        },
520                        escape_md,
521                        true,
522                    );
523
524                    let new_column_width = escaped_string.len();
525                    escaped_row.push(escaped_string);
526
527                    if column_widths[i] < new_column_width {
528                        column_widths[i] = new_column_width;
529                    }
530                    if column_widths[i] < 3 {
531                        column_widths[i] = 3;
532                    }
533                }
534            }
535            p => {
536                let value_string =
537                    v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
538                escaped_row.push(value_string);
539            }
540        }
541
542        escaped_rows.push(escaped_row);
543    }
544
545    if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
546        && escaped_rows.is_empty()
547    {
548        String::from("")
549    } else {
550        get_output_string(
551            &escaped_headers,
552            &escaped_rows,
553            &column_widths,
554            pretty,
555            center,
556        )
557        .trim()
558        .to_string()
559    }
560}
561
562pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
563    let mut lists = IndexMap::new();
564    let mut single_list = false;
565    for val in values {
566        if let Value::Record {
567            val: ref record, ..
568        } = val
569        {
570            lists
571                .entry(record.columns().map(|c| c.as_str()).collect::<String>())
572                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
573                .or_insert_with(|| vec![val.clone()]);
574        } else {
575            lists
576                .entry(val.to_expanded_string(",", config))
577                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
578                .or_insert_with(|| vec![val.clone()]);
579        }
580    }
581    let mut output = vec![];
582    for (_, mut value) in lists {
583        if value.len() == 1 {
584            output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
585        } else {
586            output.push(Value::list(value.to_vec(), head))
587        }
588    }
589    if output.len() == 1 {
590        single_list = true;
591    }
592    (Value::list(output, head).into_pipeline_data(), single_list)
593}
594
595fn get_output_string(
596    headers: &[String],
597    rows: &[Vec<String>],
598    column_widths: &[usize],
599    pretty: bool,
600    center: &Option<Vec<CellPath>>,
601) -> String {
602    let mut output_string = String::new();
603
604    let mut to_center: HashSet<String> = HashSet::new();
605    if let Some(center_vec) = center.as_ref() {
606        for cell_path in center_vec {
607            if let Some(PathMember::String { val, .. }) = cell_path
608                .members
609                .iter()
610                .find(|member| matches!(member, PathMember::String { .. }))
611            {
612                to_center.insert(val.clone());
613            }
614        }
615    }
616
617    if !headers.is_empty() {
618        output_string.push('|');
619
620        for i in 0..headers.len() {
621            output_string.push(' ');
622            if pretty {
623                if center.is_some() && to_center.contains(&headers[i]) {
624                    output_string.push_str(&get_centered_string(
625                        headers[i].clone(),
626                        column_widths[i],
627                        ' ',
628                    ));
629                } else {
630                    output_string.push_str(&get_padded_string(
631                        headers[i].clone(),
632                        column_widths[i],
633                        ' ',
634                    ));
635                }
636            } else {
637                output_string.push_str(&headers[i]);
638            }
639
640            output_string.push_str(" |");
641        }
642
643        output_string.push_str("\n|");
644
645        for i in 0..headers.len() {
646            let centered_column = center.is_some() && to_center.contains(&headers[i]);
647            let border_char = if centered_column { ':' } else { ' ' };
648            if pretty {
649                output_string.push(border_char);
650                output_string.push_str(&get_padded_string(
651                    String::from("-"),
652                    column_widths[i],
653                    '-',
654                ));
655                output_string.push(border_char);
656            } else if centered_column {
657                output_string.push_str(":---:");
658            } else {
659                output_string.push_str(" --- ");
660            }
661
662            output_string.push('|');
663        }
664
665        output_string.push('\n');
666    }
667
668    for row in rows {
669        if !headers.is_empty() {
670            output_string.push('|');
671        }
672
673        for i in 0..row.len() {
674            if !headers.is_empty() {
675                output_string.push(' ');
676            }
677
678            if pretty && column_widths.get(i).is_some() {
679                if center.is_some() && to_center.contains(&headers[i]) {
680                    output_string.push_str(&get_centered_string(
681                        row[i].clone(),
682                        column_widths[i],
683                        ' ',
684                    ));
685                } else {
686                    output_string.push_str(&get_padded_string(
687                        row[i].clone(),
688                        column_widths[i],
689                        ' ',
690                    ));
691                }
692            } else {
693                output_string.push_str(&row[i]);
694            }
695
696            if !headers.is_empty() {
697                output_string.push_str(" |");
698            }
699        }
700
701        output_string.push('\n');
702    }
703
704    output_string
705}
706
707fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
708    let total_padding = if text.len() > desired_length {
709        0
710    } else {
711        desired_length - text.len()
712    };
713
714    let repeat_left = total_padding / 2;
715    let repeat_right = total_padding - repeat_left;
716
717    format!(
718        "{}{}{}",
719        padding_character.to_string().repeat(repeat_left),
720        text,
721        padding_character.to_string().repeat(repeat_right)
722    )
723}
724
725fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
726    let repeat_length = if text.len() > desired_length {
727        0
728    } else {
729        desired_length - text.len()
730    };
731
732    format!(
733        "{}{}",
734        text,
735        padding_character.to_string().repeat(repeat_length)
736    )
737}
738
739#[cfg(test)]
740mod tests {
741    use crate::{Get, Metadata};
742
743    use super::*;
744    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
745    use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
746
747    fn one(string: &str) -> String {
748        string
749            .lines()
750            .skip(1)
751            .map(|line| line.trim())
752            .collect::<Vec<&str>>()
753            .join("\n")
754            .trim_end()
755            .to_string()
756    }
757
758    #[test]
759    fn test_examples() {
760        use crate::test_examples;
761
762        test_examples(ToMd {})
763    }
764
765    #[test]
766    fn render_h1() {
767        let value = Value::test_record(record! {
768            "H1" => Value::test_string("Ecuador"),
769        });
770
771        assert_eq!(
772            fragment(value, false, &None, false, false, &Config::default()),
773            "# Ecuador\n"
774        );
775    }
776
777    #[test]
778    fn render_h2() {
779        let value = Value::test_record(record! {
780            "H2" => Value::test_string("Ecuador"),
781        });
782
783        assert_eq!(
784            fragment(value, false, &None, false, false, &Config::default()),
785            "## Ecuador\n"
786        );
787    }
788
789    #[test]
790    fn render_h3() {
791        let value = Value::test_record(record! {
792            "H3" => Value::test_string("Ecuador"),
793        });
794
795        assert_eq!(
796            fragment(value, false, &None, false, false, &Config::default()),
797            "### Ecuador\n"
798        );
799    }
800
801    #[test]
802    fn render_blockquote() {
803        let value = Value::test_record(record! {
804            "BLOCKQUOTE" => Value::test_string("Ecuador"),
805        });
806
807        assert_eq!(
808            fragment(value, false, &None, false, false, &Config::default()),
809            "> Ecuador\n"
810        );
811    }
812
813    #[test]
814    fn render_table() {
815        let value = Value::test_list(vec![
816            Value::test_record(record! {
817                "country" => Value::test_string("Ecuador"),
818            }),
819            Value::test_record(record! {
820                "country" => Value::test_string("New Zealand"),
821            }),
822            Value::test_record(record! {
823                "country" => Value::test_string("USA"),
824            }),
825        ]);
826
827        assert_eq!(
828            table(
829                value.clone().into_pipeline_data(),
830                false,
831                &None,
832                false,
833                false,
834                &Config::default()
835            ),
836            one(r#"
837            | country |
838            | --- |
839            | Ecuador |
840            | New Zealand |
841            | USA |
842            "#)
843        );
844
845        assert_eq!(
846            table(
847                value.into_pipeline_data(),
848                true,
849                &None,
850                false,
851                false,
852                &Config::default()
853            ),
854            one(r#"
855            | country     |
856            | ----------- |
857            | Ecuador     |
858            | New Zealand |
859            | USA         |
860            "#)
861        );
862    }
863
864    #[test]
865    fn test_empty_column_header() {
866        let value = Value::test_list(vec![
867            Value::test_record(record! {
868                "" => Value::test_string("1"),
869                "foo" => Value::test_string("2"),
870            }),
871            Value::test_record(record! {
872                "" => Value::test_string("3"),
873                "foo" => Value::test_string("4"),
874            }),
875        ]);
876
877        assert_eq!(
878            table(
879                value.clone().into_pipeline_data(),
880                false,
881                &None,
882                false,
883                false,
884                &Config::default()
885            ),
886            one(r#"
887            |  | foo |
888            | --- | --- |
889            | 1 | 2 |
890            | 3 | 4 |
891            "#)
892        );
893    }
894
895    #[test]
896    fn test_empty_row_value() {
897        let value = Value::test_list(vec![
898            Value::test_record(record! {
899                "foo" => Value::test_string("1"),
900                "bar" => Value::test_string("2"),
901            }),
902            Value::test_record(record! {
903                "foo" => Value::test_string("3"),
904                "bar" => Value::test_string("4"),
905            }),
906            Value::test_record(record! {
907                "foo" => Value::test_string("5"),
908                "bar" => Value::test_string(""),
909            }),
910        ]);
911
912        assert_eq!(
913            table(
914                value.clone().into_pipeline_data(),
915                false,
916                &None,
917                false,
918                false,
919                &Config::default()
920            ),
921            one(r#"
922            | foo | bar |
923            | --- | --- |
924            | 1 | 2 |
925            | 3 | 4 |
926            | 5 |  |
927            "#)
928        );
929    }
930
931    #[test]
932    fn test_center_column() {
933        let value = Value::test_list(vec![
934            Value::test_record(record! {
935                "foo" => Value::test_string("1"),
936                "bar" => Value::test_string("2"),
937            }),
938            Value::test_record(record! {
939                "foo" => Value::test_string("3"),
940                "bar" => Value::test_string("4"),
941            }),
942            Value::test_record(record! {
943                "foo" => Value::test_string("5"),
944                "bar" => Value::test_string("6"),
945            }),
946        ]);
947
948        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
949            members: vec![PathMember::test_string(
950                "bar".into(),
951                false,
952                Casing::Sensitive,
953            )],
954        })]);
955
956        let cell_path: Vec<CellPath> = center_columns
957            .into_list()
958            .unwrap()
959            .into_iter()
960            .map(|v| v.into_cell_path().unwrap())
961            .collect();
962
963        let center: Option<Vec<CellPath>> = Some(cell_path);
964
965        // With pretty
966        assert_eq!(
967            table(
968                value.clone().into_pipeline_data(),
969                true,
970                &center,
971                false,
972                false,
973                &Config::default()
974            ),
975            one(r#"
976            | foo | bar |
977            | --- |:---:|
978            | 1   |  2  |
979            | 3   |  4  |
980            | 5   |  6  |
981            "#)
982        );
983
984        // Without pretty
985        assert_eq!(
986            table(
987                value.clone().into_pipeline_data(),
988                false,
989                &center,
990                false,
991                false,
992                &Config::default()
993            ),
994            one(r#"
995            | foo | bar |
996            | --- |:---:|
997            | 1 | 2 |
998            | 3 | 4 |
999            | 5 | 6 |
1000            "#)
1001        );
1002    }
1003
1004    #[test]
1005    fn test_empty_center_column() {
1006        let value = Value::test_list(vec![
1007            Value::test_record(record! {
1008                "foo" => Value::test_string("1"),
1009                "bar" => Value::test_string("2"),
1010            }),
1011            Value::test_record(record! {
1012                "foo" => Value::test_string("3"),
1013                "bar" => Value::test_string("4"),
1014            }),
1015            Value::test_record(record! {
1016                "foo" => Value::test_string("5"),
1017                "bar" => Value::test_string("6"),
1018            }),
1019        ]);
1020
1021        let center: Option<Vec<CellPath>> = Some(vec![]);
1022
1023        assert_eq!(
1024            table(
1025                value.clone().into_pipeline_data(),
1026                true,
1027                &center,
1028                false,
1029                false,
1030                &Config::default()
1031            ),
1032            one(r#"
1033            | foo | bar |
1034            | --- | --- |
1035            | 1   | 2   |
1036            | 3   | 4   |
1037            | 5   | 6   |
1038            "#)
1039        );
1040    }
1041
1042    #[test]
1043    fn test_center_multiple_columns() {
1044        let value = Value::test_list(vec![
1045            Value::test_record(record! {
1046                "command" => Value::test_string("ls"),
1047                "input" => Value::test_string("."),
1048                "output" => Value::test_string("file.txt"),
1049            }),
1050            Value::test_record(record! {
1051                "command" => Value::test_string("echo"),
1052                "input" => Value::test_string("'hi'"),
1053                "output" => Value::test_string("hi"),
1054            }),
1055            Value::test_record(record! {
1056                "command" => Value::test_string("cp"),
1057                "input" => Value::test_string("a.txt"),
1058                "output" => Value::test_string("b.txt"),
1059            }),
1060        ]);
1061
1062        let center_columns = Value::test_list(vec![
1063            Value::test_cell_path(CellPath {
1064                members: vec![PathMember::test_string(
1065                    "command".into(),
1066                    false,
1067                    Casing::Sensitive,
1068                )],
1069            }),
1070            Value::test_cell_path(CellPath {
1071                members: vec![PathMember::test_string(
1072                    "output".into(),
1073                    false,
1074                    Casing::Sensitive,
1075                )],
1076            }),
1077        ]);
1078
1079        let cell_path: Vec<CellPath> = center_columns
1080            .into_list()
1081            .unwrap()
1082            .into_iter()
1083            .map(|v| v.into_cell_path().unwrap())
1084            .collect();
1085
1086        let center: Option<Vec<CellPath>> = Some(cell_path);
1087
1088        assert_eq!(
1089            table(
1090                value.clone().into_pipeline_data(),
1091                true,
1092                &center,
1093                false,
1094                false,
1095                &Config::default()
1096            ),
1097            one(r#"
1098            | command | input |  output  |
1099            |:-------:| ----- |:--------:|
1100            |   ls    | .     | file.txt |
1101            |  echo   | 'hi'  |    hi    |
1102            |   cp    | a.txt |  b.txt   |
1103            "#)
1104        );
1105    }
1106
1107    #[test]
1108    fn test_center_non_existing_column() {
1109        let value = Value::test_list(vec![
1110            Value::test_record(record! {
1111                "name" => Value::test_string("Alice"),
1112                "age" => Value::test_string("30"),
1113            }),
1114            Value::test_record(record! {
1115                "name" => Value::test_string("Bob"),
1116                "age" => Value::test_string("5"),
1117            }),
1118            Value::test_record(record! {
1119                "name" => Value::test_string("Charlie"),
1120                "age" => Value::test_string("20"),
1121            }),
1122        ]);
1123
1124        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1125            members: vec![PathMember::test_string(
1126                "none".into(),
1127                false,
1128                Casing::Sensitive,
1129            )],
1130        })]);
1131
1132        let cell_path: Vec<CellPath> = center_columns
1133            .into_list()
1134            .unwrap()
1135            .into_iter()
1136            .map(|v| v.into_cell_path().unwrap())
1137            .collect();
1138
1139        let center: Option<Vec<CellPath>> = Some(cell_path);
1140
1141        assert_eq!(
1142            table(
1143                value.clone().into_pipeline_data(),
1144                true,
1145                &center,
1146                false,
1147                false,
1148                &Config::default()
1149            ),
1150            one(r#"
1151            | name    | age |
1152            | ------- | --- |
1153            | Alice   | 30  |
1154            | Bob     | 5   |
1155            | Charlie | 20  |
1156            "#)
1157        );
1158    }
1159
1160    #[test]
1161    fn test_center_complex_cell_path() {
1162        let value = Value::test_list(vec![
1163            Value::test_record(record! {
1164                "k" => Value::test_string("version"),
1165                "v" => Value::test_string("0.104.1"),
1166            }),
1167            Value::test_record(record! {
1168                "k" => Value::test_string("build_time"),
1169                "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1170            }),
1171        ]);
1172
1173        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1174            members: vec![
1175                PathMember::test_int(1, false),
1176                PathMember::test_string("v".into(), false, Casing::Sensitive),
1177            ],
1178        })]);
1179
1180        let cell_path: Vec<CellPath> = center_columns
1181            .into_list()
1182            .unwrap()
1183            .into_iter()
1184            .map(|v| v.into_cell_path().unwrap())
1185            .collect();
1186
1187        let center: Option<Vec<CellPath>> = Some(cell_path);
1188
1189        assert_eq!(
1190            table(
1191                value.clone().into_pipeline_data(),
1192                true,
1193                &center,
1194                false,
1195                false,
1196                &Config::default()
1197            ),
1198            one(r#"
1199            | k          |             v              |
1200            | ---------- |:--------------------------:|
1201            | version    |          0.104.1           |
1202            | build_time | 2025-05-28 11:00:45 +01:00 |
1203            "#)
1204        );
1205    }
1206
1207    #[test]
1208    fn test_content_type_metadata() {
1209        let mut engine_state = Box::new(EngineState::new());
1210        let state_delta = {
1211            // Base functions that are needed for testing
1212            // Try to keep this working set small to keep tests running as fast as possible
1213            let mut working_set = StateWorkingSet::new(&engine_state);
1214
1215            working_set.add_decl(Box::new(ToMd {}));
1216            working_set.add_decl(Box::new(Metadata {}));
1217            working_set.add_decl(Box::new(Get {}));
1218
1219            working_set.render()
1220        };
1221        let delta = state_delta;
1222
1223        engine_state
1224            .merge_delta(delta)
1225            .expect("Error merging delta");
1226
1227        let cmd = "{a: 1 b: 2} | to md  | metadata | get content_type | $in";
1228        let result = eval_pipeline_without_terminal_expression(
1229            cmd,
1230            std::env::temp_dir().as_ref(),
1231            &mut engine_state,
1232        );
1233        assert_eq!(
1234            Value::test_string("text/markdown"),
1235            result.expect("There should be a result")
1236        );
1237    }
1238
1239    #[test]
1240    fn test_escape_md_characters() {
1241        let value = Value::test_list(vec![
1242            Value::test_record(record! {
1243                "name|label" => Value::test_string("orderColumns"),
1244                "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1245            }),
1246            Value::test_record(record! {
1247                "name|label" => Value::test_string("_ref_value"),
1248                "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1249            }),
1250            Value::test_record(record! {
1251                "name|label" => Value::test_string("onChange"),
1252                "type*" => Value::test_string("(val: string) => void\\"),
1253            }),
1254        ]);
1255
1256        assert_eq!(
1257            table(
1258                value.clone().into_pipeline_data(),
1259                false,
1260                &None,
1261                false,
1262                false,
1263                &Config::default()
1264            ),
1265            one(r#"
1266            | name\|label | type* |
1267            | --- | --- |
1268            | orderColumns | 'asc' \| 'desc' \| 'none' |
1269            | _ref_value | RefObject<SampleTableRef \| null> |
1270            | onChange | (val: string) => void\\ |
1271            "#)
1272        );
1273
1274        assert_eq!(
1275            table(
1276                value.clone().into_pipeline_data(),
1277                false,
1278                &None,
1279                true,
1280                false,
1281                &Config::default()
1282            ),
1283            one(r#"
1284            | name\|label | type\* |
1285            | --- | --- |
1286            | orderColumns | 'asc' \| 'desc' \| 'none' |
1287            | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1288            | onChange | \(val: string\) =\> void\\ |
1289            "#)
1290        );
1291
1292        assert_eq!(
1293            table(
1294                value.clone().into_pipeline_data(),
1295                true,
1296                &None,
1297                false,
1298                false,
1299                &Config::default()
1300            ),
1301            one(r#"
1302            | name\|label  | type*                             |
1303            | ------------ | --------------------------------- |
1304            | orderColumns | 'asc' \| 'desc' \| 'none'         |
1305            | _ref_value   | RefObject<SampleTableRef \| null> |
1306            | onChange     | (val: string) => void\\           |
1307            "#)
1308        );
1309
1310        assert_eq!(
1311            table(
1312                value.into_pipeline_data(),
1313                true,
1314                &None,
1315                true,
1316                false,
1317                &Config::default()
1318            ),
1319            one(r#"
1320            | name\|label  | type\*                              |
1321            | ------------ | ----------------------------------- |
1322            | orderColumns | 'asc' \| 'desc' \| 'none'           |
1323            | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1324            | onChange     | \(val: string\) =\> void\\          |
1325            "#)
1326        );
1327    }
1328
1329    #[test]
1330    fn test_escape_html_characters() {
1331        let value = Value::test_list(vec![Value::test_record(record! {
1332            "tag" => Value::test_string("table"),
1333            "code" => Value::test_string(r#"<table><tr><td scope="row">Chris</td><td>HTML tables</td><td>22</td></tr><tr><td scope="row">Dennis</td><td>Web accessibility</td><td>45</td></tr></table>"#),
1334        })]);
1335
1336        assert_eq!(
1337            table(
1338                value.clone().into_pipeline_data(),
1339                false,
1340                &None,
1341                false,
1342                true,
1343                &Config::default()
1344            ),
1345            one(r#"
1346            | tag | code |
1347            | --- | --- |
1348            | table | &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; |
1349            "#)
1350        );
1351
1352        assert_eq!(
1353            table(
1354                value.into_pipeline_data(),
1355                true,
1356                &None,
1357                false,
1358                true,
1359                &Config::default()
1360            ),
1361            one(r#"
1362            | tag   | code                                                                                                                                                                                                                                                                                                                                    |
1363            | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1364            | table | &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; |
1365            "#)
1366        );
1367    }
1368
1369    #[test]
1370    fn test_list_ordered() {
1371        let value = Value::test_list(vec![
1372            Value::test_string("one"),
1373            Value::test_string("two"),
1374            Value::test_string("three"),
1375        ]);
1376
1377        let result = to_md(
1378            value.into_pipeline_data(),
1379            ToMdOptions {
1380                pretty: false,
1381                per_element: false,
1382                center: None,
1383                escape_md: false,
1384                escape_html: false,
1385                list_style: ListStyle::Ordered,
1386            },
1387            &Config::default(),
1388            Span::test_data(),
1389        )
1390        .unwrap()
1391        .into_value(Span::test_data())
1392        .unwrap()
1393        .into_string()
1394        .unwrap();
1395
1396        assert_eq!(result, "1. one\n2. two\n3. three");
1397    }
1398
1399    #[test]
1400    fn test_list_unordered() {
1401        let value = Value::test_list(vec![
1402            Value::test_string("apple"),
1403            Value::test_string("banana"),
1404            Value::test_string("cherry"),
1405        ]);
1406
1407        let result = to_md(
1408            value.into_pipeline_data(),
1409            ToMdOptions {
1410                pretty: false,
1411                per_element: false,
1412                center: None,
1413                escape_md: false,
1414                escape_html: false,
1415                list_style: ListStyle::Unordered,
1416            },
1417            &Config::default(),
1418            Span::test_data(),
1419        )
1420        .unwrap()
1421        .into_value(Span::test_data())
1422        .unwrap()
1423        .into_string()
1424        .unwrap();
1425
1426        assert_eq!(result, "* apple\n* banana\n* cherry");
1427    }
1428
1429    #[test]
1430    fn test_list_with_escape_md() {
1431        let value = Value::test_list(vec![
1432            Value::test_string("*bold*"),
1433            Value::test_string("[link]"),
1434        ]);
1435
1436        let result = to_md(
1437            value.into_pipeline_data(),
1438            ToMdOptions {
1439                pretty: false,
1440                per_element: false,
1441                center: None,
1442                escape_md: true,
1443                escape_html: false,
1444                list_style: ListStyle::Unordered,
1445            },
1446            &Config::default(),
1447            Span::test_data(),
1448        )
1449        .unwrap()
1450        .into_value(Span::test_data())
1451        .unwrap()
1452        .into_string()
1453        .unwrap();
1454
1455        assert_eq!(result, "* \\*bold\\*\n* \\[link\\]");
1456    }
1457
1458    #[test]
1459    fn test_list_none() {
1460        let value = Value::test_list(vec![
1461            Value::test_string("one"),
1462            Value::test_string("two"),
1463            Value::test_string("three"),
1464        ]);
1465
1466        let result = to_md(
1467            value.into_pipeline_data(),
1468            ToMdOptions {
1469                pretty: false,
1470                per_element: false,
1471                center: None,
1472                escape_md: false,
1473                escape_html: false,
1474                list_style: ListStyle::None,
1475            },
1476            &Config::default(),
1477            Span::test_data(),
1478        )
1479        .unwrap()
1480        .into_value(Span::test_data())
1481        .unwrap()
1482        .into_string()
1483        .unwrap();
1484
1485        assert_eq!(result, "one\ntwo\nthree");
1486    }
1487
1488    #[test]
1489    fn test_empty_list() {
1490        let value = Value::test_list(vec![]);
1491
1492        let result = to_md(
1493            value.into_pipeline_data(),
1494            ToMdOptions {
1495                pretty: false,
1496                per_element: false,
1497                center: None,
1498                escape_md: false,
1499                escape_html: false,
1500                list_style: ListStyle::Unordered,
1501            },
1502            &Config::default(),
1503            Span::test_data(),
1504        )
1505        .unwrap()
1506        .into_value(Span::test_data())
1507        .unwrap()
1508        .into_string()
1509        .unwrap();
1510
1511        assert_eq!(result, "");
1512    }
1513
1514    #[test]
1515    fn test_mixed_input_ordered() {
1516        // Test that list numbering is continuous even with h1/tables mixed in
1517        let value = Value::test_list(vec![
1518            Value::test_record(record! {
1519                "h1" => Value::test_string("Title"),
1520            }),
1521            Value::test_string("first"),
1522            Value::test_string("second"),
1523        ]);
1524
1525        let result = to_md(
1526            value.into_pipeline_data(),
1527            ToMdOptions {
1528                pretty: false,
1529                per_element: true,
1530                center: None,
1531                escape_md: false,
1532                escape_html: false,
1533                list_style: ListStyle::Ordered,
1534            },
1535            &Config::default(),
1536            Span::test_data(),
1537        )
1538        .unwrap()
1539        .into_value(Span::test_data())
1540        .unwrap()
1541        .into_string()
1542        .unwrap();
1543
1544        // h1 should not affect numbering; items should be 1. and 2.
1545        assert_eq!(result, "# Title\n1. first\n2. second");
1546    }
1547}