Skip to main content

shannon_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() -> nu_test_support::Result {
760        nu_test_support::test().examples(ToMd)
761    }
762
763    #[test]
764    fn render_h1() {
765        let value = Value::test_record(record! {
766            "H1" => Value::test_string("Ecuador"),
767        });
768
769        assert_eq!(
770            fragment(value, false, &None, false, false, &Config::default()),
771            "# Ecuador\n"
772        );
773    }
774
775    #[test]
776    fn render_h2() {
777        let value = Value::test_record(record! {
778            "H2" => Value::test_string("Ecuador"),
779        });
780
781        assert_eq!(
782            fragment(value, false, &None, false, false, &Config::default()),
783            "## Ecuador\n"
784        );
785    }
786
787    #[test]
788    fn render_h3() {
789        let value = Value::test_record(record! {
790            "H3" => Value::test_string("Ecuador"),
791        });
792
793        assert_eq!(
794            fragment(value, false, &None, false, false, &Config::default()),
795            "### Ecuador\n"
796        );
797    }
798
799    #[test]
800    fn render_blockquote() {
801        let value = Value::test_record(record! {
802            "BLOCKQUOTE" => Value::test_string("Ecuador"),
803        });
804
805        assert_eq!(
806            fragment(value, false, &None, false, false, &Config::default()),
807            "> Ecuador\n"
808        );
809    }
810
811    #[test]
812    fn render_table() {
813        let value = Value::test_list(vec![
814            Value::test_record(record! {
815                "country" => Value::test_string("Ecuador"),
816            }),
817            Value::test_record(record! {
818                "country" => Value::test_string("New Zealand"),
819            }),
820            Value::test_record(record! {
821                "country" => Value::test_string("USA"),
822            }),
823        ]);
824
825        assert_eq!(
826            table(
827                value.clone().into_pipeline_data(),
828                false,
829                &None,
830                false,
831                false,
832                &Config::default()
833            ),
834            one("
835            | country |
836            | --- |
837            | Ecuador |
838            | New Zealand |
839            | USA |
840            ")
841        );
842
843        assert_eq!(
844            table(
845                value.into_pipeline_data(),
846                true,
847                &None,
848                false,
849                false,
850                &Config::default()
851            ),
852            one("
853            | country     |
854            | ----------- |
855            | Ecuador     |
856            | New Zealand |
857            | USA         |
858            ")
859        );
860    }
861
862    #[test]
863    fn test_empty_column_header() {
864        let value = Value::test_list(vec![
865            Value::test_record(record! {
866                "" => Value::test_string("1"),
867                "foo" => Value::test_string("2"),
868            }),
869            Value::test_record(record! {
870                "" => Value::test_string("3"),
871                "foo" => Value::test_string("4"),
872            }),
873        ]);
874
875        assert_eq!(
876            table(
877                value.clone().into_pipeline_data(),
878                false,
879                &None,
880                false,
881                false,
882                &Config::default()
883            ),
884            one("
885            |  | foo |
886            | --- | --- |
887            | 1 | 2 |
888            | 3 | 4 |
889            ")
890        );
891    }
892
893    #[test]
894    fn test_empty_row_value() {
895        let value = Value::test_list(vec![
896            Value::test_record(record! {
897                "foo" => Value::test_string("1"),
898                "bar" => Value::test_string("2"),
899            }),
900            Value::test_record(record! {
901                "foo" => Value::test_string("3"),
902                "bar" => Value::test_string("4"),
903            }),
904            Value::test_record(record! {
905                "foo" => Value::test_string("5"),
906                "bar" => Value::test_string(""),
907            }),
908        ]);
909
910        assert_eq!(
911            table(
912                value.clone().into_pipeline_data(),
913                false,
914                &None,
915                false,
916                false,
917                &Config::default()
918            ),
919            one("
920            | foo | bar |
921            | --- | --- |
922            | 1 | 2 |
923            | 3 | 4 |
924            | 5 |  |
925            ")
926        );
927    }
928
929    #[test]
930    fn test_center_column() {
931        let value = Value::test_list(vec![
932            Value::test_record(record! {
933                "foo" => Value::test_string("1"),
934                "bar" => Value::test_string("2"),
935            }),
936            Value::test_record(record! {
937                "foo" => Value::test_string("3"),
938                "bar" => Value::test_string("4"),
939            }),
940            Value::test_record(record! {
941                "foo" => Value::test_string("5"),
942                "bar" => Value::test_string("6"),
943            }),
944        ]);
945
946        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
947            members: vec![PathMember::test_string(
948                "bar".into(),
949                false,
950                Casing::Sensitive,
951            )],
952        })]);
953
954        let cell_path: Vec<CellPath> = center_columns
955            .into_list()
956            .unwrap()
957            .into_iter()
958            .map(|v| v.into_cell_path().unwrap())
959            .collect();
960
961        let center: Option<Vec<CellPath>> = Some(cell_path);
962
963        // With pretty
964        assert_eq!(
965            table(
966                value.clone().into_pipeline_data(),
967                true,
968                &center,
969                false,
970                false,
971                &Config::default()
972            ),
973            one("
974            | foo | bar |
975            | --- |:---:|
976            | 1   |  2  |
977            | 3   |  4  |
978            | 5   |  6  |
979            ")
980        );
981
982        // Without pretty
983        assert_eq!(
984            table(
985                value.clone().into_pipeline_data(),
986                false,
987                &center,
988                false,
989                false,
990                &Config::default()
991            ),
992            one("
993            | foo | bar |
994            | --- |:---:|
995            | 1 | 2 |
996            | 3 | 4 |
997            | 5 | 6 |
998            ")
999        );
1000    }
1001
1002    #[test]
1003    fn test_empty_center_column() {
1004        let value = Value::test_list(vec![
1005            Value::test_record(record! {
1006                "foo" => Value::test_string("1"),
1007                "bar" => Value::test_string("2"),
1008            }),
1009            Value::test_record(record! {
1010                "foo" => Value::test_string("3"),
1011                "bar" => Value::test_string("4"),
1012            }),
1013            Value::test_record(record! {
1014                "foo" => Value::test_string("5"),
1015                "bar" => Value::test_string("6"),
1016            }),
1017        ]);
1018
1019        let center: Option<Vec<CellPath>> = Some(vec![]);
1020
1021        assert_eq!(
1022            table(
1023                value.clone().into_pipeline_data(),
1024                true,
1025                &center,
1026                false,
1027                false,
1028                &Config::default()
1029            ),
1030            one("
1031            | foo | bar |
1032            | --- | --- |
1033            | 1   | 2   |
1034            | 3   | 4   |
1035            | 5   | 6   |
1036            ")
1037        );
1038    }
1039
1040    #[test]
1041    fn test_center_multiple_columns() {
1042        let value = Value::test_list(vec![
1043            Value::test_record(record! {
1044                "command" => Value::test_string("ls"),
1045                "input" => Value::test_string("."),
1046                "output" => Value::test_string("file.txt"),
1047            }),
1048            Value::test_record(record! {
1049                "command" => Value::test_string("echo"),
1050                "input" => Value::test_string("'hi'"),
1051                "output" => Value::test_string("hi"),
1052            }),
1053            Value::test_record(record! {
1054                "command" => Value::test_string("cp"),
1055                "input" => Value::test_string("a.txt"),
1056                "output" => Value::test_string("b.txt"),
1057            }),
1058        ]);
1059
1060        let center_columns = Value::test_list(vec![
1061            Value::test_cell_path(CellPath {
1062                members: vec![PathMember::test_string(
1063                    "command".into(),
1064                    false,
1065                    Casing::Sensitive,
1066                )],
1067            }),
1068            Value::test_cell_path(CellPath {
1069                members: vec![PathMember::test_string(
1070                    "output".into(),
1071                    false,
1072                    Casing::Sensitive,
1073                )],
1074            }),
1075        ]);
1076
1077        let cell_path: Vec<CellPath> = center_columns
1078            .into_list()
1079            .unwrap()
1080            .into_iter()
1081            .map(|v| v.into_cell_path().unwrap())
1082            .collect();
1083
1084        let center: Option<Vec<CellPath>> = Some(cell_path);
1085
1086        assert_eq!(
1087            table(
1088                value.clone().into_pipeline_data(),
1089                true,
1090                &center,
1091                false,
1092                false,
1093                &Config::default()
1094            ),
1095            one("
1096            | command | input |  output  |
1097            |:-------:| ----- |:--------:|
1098            |   ls    | .     | file.txt |
1099            |  echo   | 'hi'  |    hi    |
1100            |   cp    | a.txt |  b.txt   |
1101            ")
1102        );
1103    }
1104
1105    #[test]
1106    fn test_center_non_existing_column() {
1107        let value = Value::test_list(vec![
1108            Value::test_record(record! {
1109                "name" => Value::test_string("Alice"),
1110                "age" => Value::test_string("30"),
1111            }),
1112            Value::test_record(record! {
1113                "name" => Value::test_string("Bob"),
1114                "age" => Value::test_string("5"),
1115            }),
1116            Value::test_record(record! {
1117                "name" => Value::test_string("Charlie"),
1118                "age" => Value::test_string("20"),
1119            }),
1120        ]);
1121
1122        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1123            members: vec![PathMember::test_string(
1124                "none".into(),
1125                false,
1126                Casing::Sensitive,
1127            )],
1128        })]);
1129
1130        let cell_path: Vec<CellPath> = center_columns
1131            .into_list()
1132            .unwrap()
1133            .into_iter()
1134            .map(|v| v.into_cell_path().unwrap())
1135            .collect();
1136
1137        let center: Option<Vec<CellPath>> = Some(cell_path);
1138
1139        assert_eq!(
1140            table(
1141                value.clone().into_pipeline_data(),
1142                true,
1143                &center,
1144                false,
1145                false,
1146                &Config::default()
1147            ),
1148            one("
1149            | name    | age |
1150            | ------- | --- |
1151            | Alice   | 30  |
1152            | Bob     | 5   |
1153            | Charlie | 20  |
1154            ")
1155        );
1156    }
1157
1158    #[test]
1159    fn test_center_complex_cell_path() {
1160        let value = Value::test_list(vec![
1161            Value::test_record(record! {
1162                "k" => Value::test_string("version"),
1163                "v" => Value::test_string("0.104.1"),
1164            }),
1165            Value::test_record(record! {
1166                "k" => Value::test_string("build_time"),
1167                "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
1168            }),
1169        ]);
1170
1171        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
1172            members: vec![
1173                PathMember::test_int(1, false),
1174                PathMember::test_string("v".into(), false, Casing::Sensitive),
1175            ],
1176        })]);
1177
1178        let cell_path: Vec<CellPath> = center_columns
1179            .into_list()
1180            .unwrap()
1181            .into_iter()
1182            .map(|v| v.into_cell_path().unwrap())
1183            .collect();
1184
1185        let center: Option<Vec<CellPath>> = Some(cell_path);
1186
1187        assert_eq!(
1188            table(
1189                value.clone().into_pipeline_data(),
1190                true,
1191                &center,
1192                false,
1193                false,
1194                &Config::default()
1195            ),
1196            one("
1197            | k          |             v              |
1198            | ---------- |:--------------------------:|
1199            | version    |          0.104.1           |
1200            | build_time | 2025-05-28 11:00:45 +01:00 |
1201            ")
1202        );
1203    }
1204
1205    #[test]
1206    fn test_content_type_metadata() {
1207        let mut engine_state = Box::new(EngineState::new());
1208        let state_delta = {
1209            // Base functions that are needed for testing
1210            // Try to keep this working set small to keep tests running as fast as possible
1211            let mut working_set = StateWorkingSet::new(&engine_state);
1212
1213            working_set.add_decl(Box::new(ToMd {}));
1214            working_set.add_decl(Box::new(Metadata {}));
1215            working_set.add_decl(Box::new(Get {}));
1216
1217            working_set.render()
1218        };
1219        let delta = state_delta;
1220
1221        engine_state
1222            .merge_delta(delta)
1223            .expect("Error merging delta");
1224
1225        let cmd = "{a: 1 b: 2} | to md  | metadata | get content_type | $in";
1226        let result = eval_pipeline_without_terminal_expression(
1227            cmd,
1228            std::env::temp_dir().as_ref(),
1229            &mut engine_state,
1230        );
1231        assert_eq!(
1232            Value::test_string("text/markdown"),
1233            result.expect("There should be a result")
1234        );
1235    }
1236
1237    #[test]
1238    fn test_escape_md_characters() {
1239        let value = Value::test_list(vec![
1240            Value::test_record(record! {
1241                "name|label" => Value::test_string("orderColumns"),
1242                "type*" => Value::test_string("'asc' | 'desc' | 'none'"),
1243            }),
1244            Value::test_record(record! {
1245                "name|label" => Value::test_string("_ref_value"),
1246                "type*" => Value::test_string("RefObject<SampleTableRef | null>"),
1247            }),
1248            Value::test_record(record! {
1249                "name|label" => Value::test_string("onChange"),
1250                "type*" => Value::test_string("(val: string) => void\\"),
1251            }),
1252        ]);
1253
1254        assert_eq!(
1255            table(
1256                value.clone().into_pipeline_data(),
1257                false,
1258                &None,
1259                false,
1260                false,
1261                &Config::default()
1262            ),
1263            one(r#"
1264            | name\|label | type* |
1265            | --- | --- |
1266            | orderColumns | 'asc' \| 'desc' \| 'none' |
1267            | _ref_value | RefObject<SampleTableRef \| null> |
1268            | onChange | (val: string) => void\\ |
1269            "#)
1270        );
1271
1272        assert_eq!(
1273            table(
1274                value.clone().into_pipeline_data(),
1275                false,
1276                &None,
1277                true,
1278                false,
1279                &Config::default()
1280            ),
1281            one(r#"
1282            | name\|label | type\* |
1283            | --- | --- |
1284            | orderColumns | 'asc' \| 'desc' \| 'none' |
1285            | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1286            | onChange | \(val: string\) =\> void\\ |
1287            "#)
1288        );
1289
1290        assert_eq!(
1291            table(
1292                value.clone().into_pipeline_data(),
1293                true,
1294                &None,
1295                false,
1296                false,
1297                &Config::default()
1298            ),
1299            one(r#"
1300            | name\|label  | type*                             |
1301            | ------------ | --------------------------------- |
1302            | orderColumns | 'asc' \| 'desc' \| 'none'         |
1303            | _ref_value   | RefObject<SampleTableRef \| null> |
1304            | onChange     | (val: string) => void\\           |
1305            "#)
1306        );
1307
1308        assert_eq!(
1309            table(
1310                value.into_pipeline_data(),
1311                true,
1312                &None,
1313                true,
1314                false,
1315                &Config::default()
1316            ),
1317            one(r#"
1318            | name\|label  | type\*                              |
1319            | ------------ | ----------------------------------- |
1320            | orderColumns | 'asc' \| 'desc' \| 'none'           |
1321            | \_ref\_value | RefObject\<SampleTableRef \| null\> |
1322            | onChange     | \(val: string\) =\> void\\          |
1323            "#)
1324        );
1325    }
1326
1327    #[test]
1328    fn test_escape_html_characters() {
1329        let value = Value::test_list(vec![Value::test_record(record! {
1330            "tag" => Value::test_string("table"),
1331            "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>"#),
1332        })]);
1333
1334        assert_eq!(
1335            table(
1336                value.clone().into_pipeline_data(),
1337                false,
1338                &None,
1339                false,
1340                true,
1341                &Config::default()
1342            ),
1343            one("
1344            | tag | code |
1345            | --- | --- |
1346            | 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; |
1347            ")
1348        );
1349
1350        assert_eq!(
1351            table(
1352                value.into_pipeline_data(),
1353                true,
1354                &None,
1355                false,
1356                true,
1357                &Config::default()
1358            ),
1359            one("
1360            | tag   | code                                                                                                                                                                                                                                                                                                                                    |
1361            | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1362            | 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; |
1363            ")
1364        );
1365    }
1366
1367    #[test]
1368    fn test_list_ordered() {
1369        let value = Value::test_list(vec![
1370            Value::test_string("one"),
1371            Value::test_string("two"),
1372            Value::test_string("three"),
1373        ]);
1374
1375        let result = to_md(
1376            value.into_pipeline_data(),
1377            ToMdOptions {
1378                pretty: false,
1379                per_element: false,
1380                center: None,
1381                escape_md: false,
1382                escape_html: false,
1383                list_style: ListStyle::Ordered,
1384            },
1385            &Config::default(),
1386            Span::test_data(),
1387        )
1388        .unwrap()
1389        .into_value(Span::test_data())
1390        .unwrap()
1391        .into_string()
1392        .unwrap();
1393
1394        assert_eq!(result, "1. one\n2. two\n3. three");
1395    }
1396
1397    #[test]
1398    fn test_list_unordered() {
1399        let value = Value::test_list(vec![
1400            Value::test_string("apple"),
1401            Value::test_string("banana"),
1402            Value::test_string("cherry"),
1403        ]);
1404
1405        let result = to_md(
1406            value.into_pipeline_data(),
1407            ToMdOptions {
1408                pretty: false,
1409                per_element: false,
1410                center: None,
1411                escape_md: false,
1412                escape_html: false,
1413                list_style: ListStyle::Unordered,
1414            },
1415            &Config::default(),
1416            Span::test_data(),
1417        )
1418        .unwrap()
1419        .into_value(Span::test_data())
1420        .unwrap()
1421        .into_string()
1422        .unwrap();
1423
1424        assert_eq!(result, "* apple\n* banana\n* cherry");
1425    }
1426
1427    #[test]
1428    fn test_list_with_escape_md() {
1429        let value = Value::test_list(vec![
1430            Value::test_string("*bold*"),
1431            Value::test_string("[link]"),
1432        ]);
1433
1434        let result = to_md(
1435            value.into_pipeline_data(),
1436            ToMdOptions {
1437                pretty: false,
1438                per_element: false,
1439                center: None,
1440                escape_md: true,
1441                escape_html: false,
1442                list_style: ListStyle::Unordered,
1443            },
1444            &Config::default(),
1445            Span::test_data(),
1446        )
1447        .unwrap()
1448        .into_value(Span::test_data())
1449        .unwrap()
1450        .into_string()
1451        .unwrap();
1452
1453        assert_eq!(result, "* \\*bold\\*\n* \\[link\\]");
1454    }
1455
1456    #[test]
1457    fn test_list_none() {
1458        let value = Value::test_list(vec![
1459            Value::test_string("one"),
1460            Value::test_string("two"),
1461            Value::test_string("three"),
1462        ]);
1463
1464        let result = to_md(
1465            value.into_pipeline_data(),
1466            ToMdOptions {
1467                pretty: false,
1468                per_element: false,
1469                center: None,
1470                escape_md: false,
1471                escape_html: false,
1472                list_style: ListStyle::None,
1473            },
1474            &Config::default(),
1475            Span::test_data(),
1476        )
1477        .unwrap()
1478        .into_value(Span::test_data())
1479        .unwrap()
1480        .into_string()
1481        .unwrap();
1482
1483        assert_eq!(result, "one\ntwo\nthree");
1484    }
1485
1486    #[test]
1487    fn test_empty_list() {
1488        let value = Value::test_list(vec![]);
1489
1490        let result = to_md(
1491            value.into_pipeline_data(),
1492            ToMdOptions {
1493                pretty: false,
1494                per_element: false,
1495                center: None,
1496                escape_md: false,
1497                escape_html: false,
1498                list_style: ListStyle::Unordered,
1499            },
1500            &Config::default(),
1501            Span::test_data(),
1502        )
1503        .unwrap()
1504        .into_value(Span::test_data())
1505        .unwrap()
1506        .into_string()
1507        .unwrap();
1508
1509        assert_eq!(result, "");
1510    }
1511
1512    #[test]
1513    fn test_mixed_input_ordered() {
1514        // Test that list numbering is continuous even with h1/tables mixed in
1515        let value = Value::test_list(vec![
1516            Value::test_record(record! {
1517                "h1" => Value::test_string("Title"),
1518            }),
1519            Value::test_string("first"),
1520            Value::test_string("second"),
1521        ]);
1522
1523        let result = to_md(
1524            value.into_pipeline_data(),
1525            ToMdOptions {
1526                pretty: false,
1527                per_element: true,
1528                center: None,
1529                escape_md: false,
1530                escape_html: false,
1531                list_style: ListStyle::Ordered,
1532            },
1533            &Config::default(),
1534            Span::test_data(),
1535        )
1536        .unwrap()
1537        .into_value(Span::test_data())
1538        .unwrap()
1539        .into_string()
1540        .unwrap();
1541
1542        // h1 should not affect numbering; items should be 1. and 2.
1543        assert_eq!(result, "# Title\n1. first\n2. second");
1544    }
1545}