Skip to main content

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    mut 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 = Some(
226        input
227            .take_metadata()
228            .unwrap_or_default()
229            .with_content_type(Some("text/markdown".into())),
230    );
231
232    // Collect input to check if it's a simple list (no records/tables)
233    let values: Vec<Value> = input.into_iter().collect();
234
235    // Check if input is a simple list (no records, lists, or tables)
236    // Tables in nushell can be represented as List of Records or List of Lists
237    let is_simple_list = !values
238        .iter()
239        .any(|v| matches!(v, Value::Record { .. } | Value::List { .. }));
240
241    // For simple lists, use list_style formatting
242    if is_simple_list {
243        let result = values
244            .into_iter()
245            .enumerate()
246            .map(|(idx, val)| {
247                format_list_item(
248                    val,
249                    idx,
250                    options.list_style,
251                    options.escape_md,
252                    options.escape_html,
253                    config,
254                )
255            })
256            .collect::<Vec<String>>()
257            .join("")
258            .trim()
259            .to_string();
260        return Ok(Value::string(result, head).into_pipeline_data_with_metadata(metadata));
261    }
262
263    // For tables/records, use the grouping logic
264    let input = Value::list(values, head).into_pipeline_data();
265    let (grouped_input, single_list) = group_by(input, head, config);
266    if options.per_element || single_list {
267        return Ok(Value::string(
268            grouped_input
269                .into_iter()
270                .scan(0usize, |list_idx, val| {
271                    Some(match &val {
272                        Value::List { .. } => {
273                            format!(
274                                "{}\n\n",
275                                table(
276                                    val.into_pipeline_data(),
277                                    options.pretty,
278                                    &options.center,
279                                    options.escape_md,
280                                    options.escape_html,
281                                    config
282                                )
283                            )
284                        }
285                        // For records, check if it's a special markdown element (h1, h2, etc.)
286                        Value::Record { val: record, .. } => {
287                            if is_special_markdown_record(record) {
288                                // Special markdown elements use fragment() directly
289                                fragment(
290                                    val,
291                                    options.pretty,
292                                    &options.center,
293                                    options.escape_md,
294                                    options.escape_html,
295                                    config,
296                                )
297                            } else {
298                                // Regular records are rendered as tables
299                                format!(
300                                    "{}\n\n",
301                                    fragment(
302                                        val,
303                                        options.pretty,
304                                        &options.center,
305                                        options.escape_md,
306                                        options.escape_html,
307                                        config
308                                    )
309                                )
310                            }
311                        }
312                        _ => {
313                            let result = format_list_item(
314                                val,
315                                *list_idx,
316                                options.list_style,
317                                options.escape_md,
318                                options.escape_html,
319                                config,
320                            );
321                            *list_idx += 1;
322                            result
323                        }
324                    })
325                })
326                .collect::<Vec<String>>()
327                .join("")
328                .trim(),
329            head,
330        )
331        .into_pipeline_data_with_metadata(metadata));
332    }
333    Ok(Value::string(
334        table(
335            grouped_input,
336            options.pretty,
337            &options.center,
338            options.escape_md,
339            options.escape_html,
340            config,
341        ),
342        head,
343    )
344    .into_pipeline_data_with_metadata(metadata))
345}
346
347/// Formats a single list item with the appropriate list marker based on list_style
348fn format_list_item(
349    input: Value,
350    index: usize,
351    list_style: ListStyle,
352    escape_md: bool,
353    escape_html: bool,
354    config: &Config,
355) -> String {
356    let value_string = input.to_expanded_string("|", config);
357    let escaped = escape_value(value_string, escape_md, escape_html, false);
358
359    match list_style {
360        ListStyle::Ordered => format!("{}. {}\n", index + 1, escaped),
361        ListStyle::Unordered => format!("* {}\n", escaped),
362        ListStyle::None => format!("{}\n", escaped),
363    }
364}
365
366fn escape_markdown_characters(input: String, escape_md: bool, for_table: bool) -> String {
367    let mut output = String::with_capacity(input.len());
368    for ch in input.chars() {
369        let must_escape = match ch {
370            '\\' => true,
371            '|' if for_table => true,
372            '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '<' | '>' | '#' | '+' | '-'
373            | '.' | '!'
374                if escape_md =>
375            {
376                true
377            }
378            _ => false,
379        };
380
381        if must_escape {
382            output.push('\\');
383        }
384        output.push(ch);
385    }
386    output
387}
388
389/// Escapes a value string with optional HTML and Markdown escaping
390fn escape_value(value: String, escape_md: bool, escape_html: bool, for_table: bool) -> String {
391    escape_markdown_characters(
392        if escape_html {
393            v_htmlescape::escape(&value).to_string()
394        } else {
395            value
396        },
397        escape_md,
398        for_table,
399    )
400}
401
402fn fragment(
403    input: Value,
404    pretty: bool,
405    center: &Option<Vec<CellPath>>,
406    escape_md: bool,
407    escape_html: bool,
408    config: &Config,
409) -> String {
410    let mut out = String::new();
411
412    if let Value::Record { val, .. } = &input {
413        match val.get_index(0) {
414            Some((header, data)) if is_special_markdown_record(val) => {
415                // SAFETY: is_special_markdown_record already validated the header matches one of these
416                let markup = match header.to_ascii_lowercase().as_ref() {
417                    "h1" => "# ",
418                    "h2" => "## ",
419                    "h3" => "### ",
420                    "blockquote" => "> ",
421                    _ => "> ", // Fallback for any future validated headers not yet listed explicitly
422                };
423
424                let value_string = data.to_expanded_string("|", config);
425                out.push_str(markup);
426                out.push_str(&escape_value(value_string, escape_md, escape_html, false));
427            }
428            _ => {
429                out = table(
430                    input.into_pipeline_data(),
431                    pretty,
432                    center,
433                    escape_md,
434                    escape_html,
435                    config,
436                )
437            }
438        }
439    } else {
440        let value_string = input.to_expanded_string("|", config);
441        out = escape_value(value_string, escape_md, escape_html, false);
442    }
443
444    out.push('\n');
445    out
446}
447
448fn collect_headers(headers: &[String], escape_md: bool) -> (Vec<String>, Vec<usize>) {
449    let mut escaped_headers: Vec<String> = Vec::new();
450    let mut column_widths: Vec<usize> = Vec::new();
451
452    if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
453        for header in headers {
454            let escaped_header_string = escape_markdown_characters(
455                v_htmlescape::escape(header).to_string(),
456                escape_md,
457                true,
458            );
459            column_widths.push(escaped_header_string.len());
460            escaped_headers.push(escaped_header_string);
461        }
462    } else {
463        column_widths = vec![0; headers.len()];
464    }
465
466    (escaped_headers, column_widths)
467}
468
469fn table(
470    input: PipelineData,
471    pretty: bool,
472    center: &Option<Vec<CellPath>>,
473    escape_md: bool,
474    escape_html: bool,
475    config: &Config,
476) -> String {
477    let vec_of_values = input
478        .into_iter()
479        .flat_map(|val| match val {
480            Value::List { vals, .. } => vals,
481            other => vec![other],
482        })
483        .collect::<Vec<Value>>();
484    let mut headers = merge_descriptors(&vec_of_values);
485
486    let mut empty_header_index = 0;
487    for value in &vec_of_values {
488        if let Value::Record { val, .. } = value {
489            for column in val.columns() {
490                if column.is_empty() && !headers.contains(&String::new()) {
491                    headers.insert(empty_header_index, String::new());
492                    empty_header_index += 1;
493                    break;
494                }
495                empty_header_index += 1;
496            }
497        }
498    }
499
500    let (escaped_headers, mut column_widths) = collect_headers(&headers, escape_md);
501
502    let mut escaped_rows: Vec<Vec<String>> = Vec::new();
503
504    for row in vec_of_values {
505        let mut escaped_row: Vec<String> = Vec::new();
506        let span = row.span();
507
508        match row.to_owned() {
509            Value::Record { val: row, .. } => {
510                for i in 0..headers.len() {
511                    let value_string = row
512                        .get(&headers[i])
513                        .cloned()
514                        .unwrap_or_else(|| Value::nothing(span))
515                        .to_expanded_string(", ", config);
516                    let escaped_string = escape_markdown_characters(
517                        if escape_html {
518                            v_htmlescape::escape(&value_string).to_string()
519                        } else {
520                            value_string
521                        },
522                        escape_md,
523                        true,
524                    );
525
526                    let new_column_width = escaped_string.len();
527                    escaped_row.push(escaped_string);
528
529                    if column_widths[i] < new_column_width {
530                        column_widths[i] = new_column_width;
531                    }
532                    if column_widths[i] < 3 {
533                        column_widths[i] = 3;
534                    }
535                }
536            }
537            p => {
538                let value_string =
539                    v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
540                escaped_row.push(value_string);
541            }
542        }
543
544        escaped_rows.push(escaped_row);
545    }
546
547    if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
548        && escaped_rows.is_empty()
549    {
550        String::from("")
551    } else {
552        get_output_string(
553            &escaped_headers,
554            &escaped_rows,
555            &column_widths,
556            pretty,
557            center,
558        )
559        .trim()
560        .to_string()
561    }
562}
563
564pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
565    let mut lists = IndexMap::new();
566    let mut single_list = false;
567    for val in values {
568        if let Value::Record {
569            val: ref record, ..
570        } = val
571        {
572            lists
573                .entry(record.columns().map(|c| c.as_str()).collect::<String>())
574                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
575                .or_insert_with(|| vec![val.clone()]);
576        } else {
577            lists
578                .entry(val.to_expanded_string(",", config))
579                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
580                .or_insert_with(|| vec![val.clone()]);
581        }
582    }
583    let mut output = vec![];
584    for (_, mut value) in lists {
585        if value.len() == 1 {
586            output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
587        } else {
588            output.push(Value::list(value.to_vec(), head))
589        }
590    }
591    if output.len() == 1 {
592        single_list = true;
593    }
594    (Value::list(output, head).into_pipeline_data(), single_list)
595}
596
597fn get_output_string(
598    headers: &[String],
599    rows: &[Vec<String>],
600    column_widths: &[usize],
601    pretty: bool,
602    center: &Option<Vec<CellPath>>,
603) -> String {
604    let mut output_string = String::new();
605
606    let mut to_center: HashSet<String> = HashSet::new();
607    if let Some(center_vec) = center.as_ref() {
608        for cell_path in center_vec {
609            if let Some(PathMember::String { val, .. }) = cell_path
610                .members
611                .iter()
612                .find(|member| matches!(member, PathMember::String { .. }))
613            {
614                to_center.insert(val.clone());
615            }
616        }
617    }
618
619    if !headers.is_empty() {
620        output_string.push('|');
621
622        for i in 0..headers.len() {
623            output_string.push(' ');
624            if pretty {
625                if center.is_some() && to_center.contains(&headers[i]) {
626                    output_string.push_str(&get_centered_string(
627                        headers[i].clone(),
628                        column_widths[i],
629                        ' ',
630                    ));
631                } else {
632                    output_string.push_str(&get_padded_string(
633                        headers[i].clone(),
634                        column_widths[i],
635                        ' ',
636                    ));
637                }
638            } else {
639                output_string.push_str(&headers[i]);
640            }
641
642            output_string.push_str(" |");
643        }
644
645        output_string.push_str("\n|");
646
647        for i in 0..headers.len() {
648            let centered_column = center.is_some() && to_center.contains(&headers[i]);
649            let border_char = if centered_column { ':' } else { ' ' };
650            if pretty {
651                output_string.push(border_char);
652                output_string.push_str(&get_padded_string(
653                    String::from("-"),
654                    column_widths[i],
655                    '-',
656                ));
657                output_string.push(border_char);
658            } else if centered_column {
659                output_string.push_str(":---:");
660            } else {
661                output_string.push_str(" --- ");
662            }
663
664            output_string.push('|');
665        }
666
667        output_string.push('\n');
668    }
669
670    for row in rows {
671        if !headers.is_empty() {
672            output_string.push('|');
673        }
674
675        for i in 0..row.len() {
676            if !headers.is_empty() {
677                output_string.push(' ');
678            }
679
680            if pretty && column_widths.get(i).is_some() {
681                if center.is_some() && to_center.contains(&headers[i]) {
682                    output_string.push_str(&get_centered_string(
683                        row[i].clone(),
684                        column_widths[i],
685                        ' ',
686                    ));
687                } else {
688                    output_string.push_str(&get_padded_string(
689                        row[i].clone(),
690                        column_widths[i],
691                        ' ',
692                    ));
693                }
694            } else {
695                output_string.push_str(&row[i]);
696            }
697
698            if !headers.is_empty() {
699                output_string.push_str(" |");
700            }
701        }
702
703        output_string.push('\n');
704    }
705
706    output_string
707}
708
709fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
710    let total_padding = if text.len() > desired_length {
711        0
712    } else {
713        desired_length - text.len()
714    };
715
716    let repeat_left = total_padding / 2;
717    let repeat_right = total_padding - repeat_left;
718
719    format!(
720        "{}{}{}",
721        padding_character.to_string().repeat(repeat_left),
722        text,
723        padding_character.to_string().repeat(repeat_right)
724    )
725}
726
727fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
728    let repeat_length = if text.len() > desired_length {
729        0
730    } else {
731        desired_length - text.len()
732    };
733
734    format!(
735        "{}{}",
736        text,
737        padding_character.to_string().repeat(repeat_length)
738    )
739}
740
741#[cfg(test)]
742mod tests {
743    use crate::{Get, Metadata};
744
745    use super::*;
746    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
747    use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
748
749    fn one(string: &str) -> String {
750        string
751            .lines()
752            .skip(1)
753            .map(|line| line.trim())
754            .collect::<Vec<&str>>()
755            .join("\n")
756            .trim_end()
757            .to_string()
758    }
759
760    #[test]
761    fn test_examples() -> nu_test_support::Result {
762        nu_test_support::test().examples(ToMd)
763    }
764
765    #[test]
766    fn render_h1() {
767        let value = Value::test_record(record! {
768            "H1" => Value::test_string("Ecuador"),
769        });
770
771        assert_eq!(
772            fragment(value, false, &None, false, false, &Config::default()),
773            "# Ecuador\n"
774        );
775    }
776
777    #[test]
778    fn render_h2() {
779        let value = Value::test_record(record! {
780            "H2" => Value::test_string("Ecuador"),
781        });
782
783        assert_eq!(
784            fragment(value, false, &None, false, false, &Config::default()),
785            "## Ecuador\n"
786        );
787    }
788
789    #[test]
790    fn render_h3() {
791        let value = Value::test_record(record! {
792            "H3" => Value::test_string("Ecuador"),
793        });
794
795        assert_eq!(
796            fragment(value, false, &None, false, false, &Config::default()),
797            "### Ecuador\n"
798        );
799    }
800
801    #[test]
802    fn render_blockquote() {
803        let value = Value::test_record(record! {
804            "BLOCKQUOTE" => Value::test_string("Ecuador"),
805        });
806
807        assert_eq!(
808            fragment(value, false, &None, false, false, &Config::default()),
809            "> Ecuador\n"
810        );
811    }
812
813    #[test]
814    fn render_table() {
815        let value = Value::test_list(vec![
816            Value::test_record(record! {
817                "country" => Value::test_string("Ecuador"),
818            }),
819            Value::test_record(record! {
820                "country" => Value::test_string("New Zealand"),
821            }),
822            Value::test_record(record! {
823                "country" => Value::test_string("USA"),
824            }),
825        ]);
826
827        assert_eq!(
828            table(
829                value.clone().into_pipeline_data(),
830                false,
831                &None,
832                false,
833                false,
834                &Config::default()
835            ),
836            one("
837            | country |
838            | --- |
839            | Ecuador |
840            | New Zealand |
841            | USA |
842            ")
843        );
844
845        assert_eq!(
846            table(
847                value.into_pipeline_data(),
848                true,
849                &None,
850                false,
851                false,
852                &Config::default()
853            ),
854            one("
855            | country     |
856            | ----------- |
857            | Ecuador     |
858            | New Zealand |
859            | USA         |
860            ")
861        );
862    }
863
864    #[test]
865    fn test_empty_column_header() {
866        let value = Value::test_list(vec![
867            Value::test_record(record! {
868                "" => Value::test_string("1"),
869                "foo" => Value::test_string("2"),
870            }),
871            Value::test_record(record! {
872                "" => Value::test_string("3"),
873                "foo" => Value::test_string("4"),
874            }),
875        ]);
876
877        assert_eq!(
878            table(
879                value.clone().into_pipeline_data(),
880                false,
881                &None,
882                false,
883                false,
884                &Config::default()
885            ),
886            one("
887            |  | foo |
888            | --- | --- |
889            | 1 | 2 |
890            | 3 | 4 |
891            ")
892        );
893    }
894
895    #[test]
896    fn test_empty_row_value() {
897        let value = Value::test_list(vec![
898            Value::test_record(record! {
899                "foo" => Value::test_string("1"),
900                "bar" => Value::test_string("2"),
901            }),
902            Value::test_record(record! {
903                "foo" => Value::test_string("3"),
904                "bar" => Value::test_string("4"),
905            }),
906            Value::test_record(record! {
907                "foo" => Value::test_string("5"),
908                "bar" => Value::test_string(""),
909            }),
910        ]);
911
912        assert_eq!(
913            table(
914                value.clone().into_pipeline_data(),
915                false,
916                &None,
917                false,
918                false,
919                &Config::default()
920            ),
921            one("
922            | foo | bar |
923            | --- | --- |
924            | 1 | 2 |
925            | 3 | 4 |
926            | 5 |  |
927            ")
928        );
929    }
930
931    #[test]
932    fn test_center_column() {
933        let value = Value::test_list(vec![
934            Value::test_record(record! {
935                "foo" => Value::test_string("1"),
936                "bar" => Value::test_string("2"),
937            }),
938            Value::test_record(record! {
939                "foo" => Value::test_string("3"),
940                "bar" => Value::test_string("4"),
941            }),
942            Value::test_record(record! {
943                "foo" => Value::test_string("5"),
944                "bar" => Value::test_string("6"),
945            }),
946        ]);
947
948        let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
949            members: vec![PathMember::test_string(
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("
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("
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("
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("
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("
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("
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("
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("
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}