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;
5
6#[derive(Clone)]
7pub struct ToMd;
8
9impl Command for ToMd {
10    fn name(&self) -> &str {
11        "to md"
12    }
13
14    fn signature(&self) -> Signature {
15        Signature::build("to md")
16            .input_output_types(vec![(Type::Any, Type::String)])
17            .switch(
18                "pretty",
19                "Formats the Markdown table to vertically align items",
20                Some('p'),
21            )
22            .switch(
23                "per-element",
24                "treat each row as markdown syntax element",
25                Some('e'),
26            )
27            .category(Category::Formats)
28    }
29
30    fn description(&self) -> &str {
31        "Convert table into simple Markdown."
32    }
33
34    fn examples(&self) -> Vec<Example> {
35        vec![
36            Example {
37                description: "Outputs an MD string representing the contents of this table",
38                example: "[[foo bar]; [1 2]] | to md",
39                result: Some(Value::test_string("|foo|bar|\n|-|-|\n|1|2|")),
40            },
41            Example {
42                description: "Optionally, output a formatted markdown string",
43                example: "[[foo bar]; [1 2]] | to md --pretty",
44                result: Some(Value::test_string(
45                    "| foo | bar |\n| --- | --- |\n| 1   | 2   |",
46                )),
47            },
48            Example {
49                description: "Treat each row as a markdown element",
50                example: r#"[{"H1": "Welcome to Nushell" } [[foo bar]; [1 2]]] | to md --per-element --pretty"#,
51                result: Some(Value::test_string(
52                    "# Welcome to Nushell\n| foo | bar |\n| --- | --- |\n| 1   | 2   |",
53                )),
54            },
55            Example {
56                description: "Render a list",
57                example: "[0 1 2] | to md --pretty",
58                result: Some(Value::test_string("0\n1\n2")),
59            },
60            Example {
61                description: "Separate list into markdown tables",
62                example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4} {foo: 5}] | to md --per-element",
63                result: Some(Value::test_string(
64                    "|foo|bar|\n|-|-|\n|1|2|\n|3|4|\n|foo|\n|-|\n|5|",
65                )),
66            },
67        ]
68    }
69
70    fn run(
71        &self,
72        engine_state: &EngineState,
73        stack: &mut Stack,
74        call: &Call,
75        input: PipelineData,
76    ) -> Result<PipelineData, ShellError> {
77        let head = call.head;
78        let pretty = call.has_flag(engine_state, stack, "pretty")?;
79        let per_element = call.has_flag(engine_state, stack, "per-element")?;
80        let config = stack.get_config(engine_state);
81        to_md(input, pretty, per_element, &config, head)
82    }
83}
84
85fn to_md(
86    input: PipelineData,
87    pretty: bool,
88    per_element: bool,
89    config: &Config,
90    head: Span,
91) -> Result<PipelineData, ShellError> {
92    // text/markdown became a valid mimetype with rfc7763
93    let metadata = input
94        .metadata()
95        .unwrap_or_default()
96        .with_content_type(Some("text/markdown".into()));
97
98    let (grouped_input, single_list) = group_by(input, head, config);
99    if per_element || single_list {
100        return Ok(Value::string(
101            grouped_input
102                .into_iter()
103                .map(move |val| match val {
104                    Value::List { .. } => {
105                        format!("{}\n", table(val.into_pipeline_data(), pretty, config))
106                    }
107                    other => fragment(other, pretty, config),
108                })
109                .collect::<Vec<String>>()
110                .join("")
111                .trim(),
112            head,
113        )
114        .into_pipeline_data_with_metadata(Some(metadata)));
115    }
116    Ok(Value::string(table(grouped_input, pretty, config), head)
117        .into_pipeline_data_with_metadata(Some(metadata)))
118}
119
120fn fragment(input: Value, pretty: bool, config: &Config) -> String {
121    let mut out = String::new();
122
123    if let Value::Record { val, .. } = &input {
124        match val.get_index(0) {
125            Some((header, data)) if val.len() == 1 => {
126                let markup = match header.to_ascii_lowercase().as_ref() {
127                    "h1" => "# ".to_string(),
128                    "h2" => "## ".to_string(),
129                    "h3" => "### ".to_string(),
130                    "blockquote" => "> ".to_string(),
131                    _ => return table(input.into_pipeline_data(), pretty, config),
132                };
133
134                out.push_str(&markup);
135                out.push_str(&data.to_expanded_string("|", config));
136            }
137            _ => out = table(input.into_pipeline_data(), pretty, config),
138        }
139    } else {
140        out = input.to_expanded_string("|", config)
141    }
142
143    out.push('\n');
144    out
145}
146
147fn collect_headers(headers: &[String]) -> (Vec<String>, Vec<usize>) {
148    let mut escaped_headers: Vec<String> = Vec::new();
149    let mut column_widths: Vec<usize> = Vec::new();
150
151    if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
152        for header in headers {
153            let escaped_header_string = v_htmlescape::escape(header).to_string();
154            column_widths.push(escaped_header_string.len());
155            escaped_headers.push(escaped_header_string);
156        }
157    } else {
158        column_widths = vec![0; headers.len()]
159    }
160
161    (escaped_headers, column_widths)
162}
163
164fn table(input: PipelineData, pretty: bool, config: &Config) -> String {
165    let vec_of_values = input
166        .into_iter()
167        .flat_map(|val| match val {
168            Value::List { vals, .. } => vals,
169            other => vec![other],
170        })
171        .collect::<Vec<Value>>();
172    let mut headers = merge_descriptors(&vec_of_values);
173
174    let mut empty_header_index = 0;
175    for value in &vec_of_values {
176        if let Value::Record { val, .. } = value {
177            for column in val.columns() {
178                if column.is_empty() && !headers.contains(&String::new()) {
179                    headers.insert(empty_header_index, String::new());
180                    empty_header_index += 1;
181                    break;
182                }
183                empty_header_index += 1;
184            }
185        }
186    }
187
188    let (escaped_headers, mut column_widths) = collect_headers(&headers);
189
190    let mut escaped_rows: Vec<Vec<String>> = Vec::new();
191
192    for row in vec_of_values {
193        let mut escaped_row: Vec<String> = Vec::new();
194        let span = row.span();
195
196        match row.to_owned() {
197            Value::Record { val: row, .. } => {
198                for i in 0..headers.len() {
199                    let value_string = row
200                        .get(&headers[i])
201                        .cloned()
202                        .unwrap_or_else(|| Value::nothing(span))
203                        .to_expanded_string(", ", config);
204                    let new_column_width = value_string.len();
205
206                    escaped_row.push(value_string);
207
208                    if column_widths[i] < new_column_width {
209                        column_widths[i] = new_column_width;
210                    }
211                }
212            }
213            p => {
214                let value_string =
215                    v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
216                escaped_row.push(value_string);
217            }
218        }
219
220        escaped_rows.push(escaped_row);
221    }
222
223    let output_string = if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
224        && escaped_rows.is_empty()
225    {
226        String::from("")
227    } else {
228        get_output_string(&escaped_headers, &escaped_rows, &column_widths, pretty)
229            .trim()
230            .to_string()
231    };
232
233    output_string
234}
235
236pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
237    let mut lists = IndexMap::new();
238    let mut single_list = false;
239    for val in values {
240        if let Value::Record {
241            val: ref record, ..
242        } = val
243        {
244            lists
245                .entry(record.columns().map(|c| c.as_str()).collect::<String>())
246                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
247                .or_insert_with(|| vec![val.clone()]);
248        } else {
249            lists
250                .entry(val.to_expanded_string(",", config))
251                .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
252                .or_insert_with(|| vec![val.clone()]);
253        }
254    }
255    let mut output = vec![];
256    for (_, mut value) in lists {
257        if value.len() == 1 {
258            output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
259        } else {
260            output.push(Value::list(value.to_vec(), head))
261        }
262    }
263    if output.len() == 1 {
264        single_list = true;
265    }
266    (Value::list(output, head).into_pipeline_data(), single_list)
267}
268
269fn get_output_string(
270    headers: &[String],
271    rows: &[Vec<String>],
272    column_widths: &[usize],
273    pretty: bool,
274) -> String {
275    let mut output_string = String::new();
276
277    if !headers.is_empty() {
278        output_string.push('|');
279
280        for i in 0..headers.len() {
281            if pretty {
282                output_string.push(' ');
283                output_string.push_str(&get_padded_string(
284                    headers[i].clone(),
285                    column_widths[i],
286                    ' ',
287                ));
288                output_string.push(' ');
289            } else {
290                output_string.push_str(&headers[i]);
291            }
292
293            output_string.push('|');
294        }
295
296        output_string.push_str("\n|");
297
298        for &col_width in column_widths.iter().take(headers.len()) {
299            if pretty {
300                output_string.push(' ');
301                output_string.push_str(&get_padded_string(String::from("-"), col_width, '-'));
302                output_string.push(' ');
303            } else {
304                output_string.push('-');
305            }
306
307            output_string.push('|');
308        }
309
310        output_string.push('\n');
311    }
312
313    for row in rows {
314        if !headers.is_empty() {
315            output_string.push('|');
316        }
317
318        for i in 0..row.len() {
319            if pretty && column_widths.get(i).is_some() {
320                output_string.push(' ');
321                output_string.push_str(&get_padded_string(row[i].clone(), column_widths[i], ' '));
322                output_string.push(' ');
323            } else {
324                output_string.push_str(&row[i]);
325            }
326
327            if !headers.is_empty() {
328                output_string.push('|');
329            }
330        }
331
332        output_string.push('\n');
333    }
334
335    output_string
336}
337
338fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
339    let repeat_length = if text.len() > desired_length {
340        0
341    } else {
342        desired_length - text.len()
343    };
344
345    format!(
346        "{}{}",
347        text,
348        padding_character.to_string().repeat(repeat_length)
349    )
350}
351
352#[cfg(test)]
353mod tests {
354    use crate::{Get, Metadata};
355
356    use super::*;
357    use nu_cmd_lang::eval_pipeline_without_terminal_expression;
358    use nu_protocol::{record, Config, IntoPipelineData, Value};
359
360    fn one(string: &str) -> String {
361        string
362            .lines()
363            .skip(1)
364            .map(|line| line.trim())
365            .collect::<Vec<&str>>()
366            .join("\n")
367            .trim_end()
368            .to_string()
369    }
370
371    #[test]
372    fn test_examples() {
373        use crate::test_examples;
374
375        test_examples(ToMd {})
376    }
377
378    #[test]
379    fn render_h1() {
380        let value = Value::test_record(record! {
381            "H1" => Value::test_string("Ecuador"),
382        });
383
384        assert_eq!(fragment(value, false, &Config::default()), "# Ecuador\n");
385    }
386
387    #[test]
388    fn render_h2() {
389        let value = Value::test_record(record! {
390            "H2" => Value::test_string("Ecuador"),
391        });
392
393        assert_eq!(fragment(value, false, &Config::default()), "## Ecuador\n");
394    }
395
396    #[test]
397    fn render_h3() {
398        let value = Value::test_record(record! {
399            "H3" => Value::test_string("Ecuador"),
400        });
401
402        assert_eq!(fragment(value, false, &Config::default()), "### Ecuador\n");
403    }
404
405    #[test]
406    fn render_blockquote() {
407        let value = Value::test_record(record! {
408            "BLOCKQUOTE" => Value::test_string("Ecuador"),
409        });
410
411        assert_eq!(fragment(value, false, &Config::default()), "> Ecuador\n");
412    }
413
414    #[test]
415    fn render_table() {
416        let value = Value::test_list(vec![
417            Value::test_record(record! {
418                "country" => Value::test_string("Ecuador"),
419            }),
420            Value::test_record(record! {
421                "country" => Value::test_string("New Zealand"),
422            }),
423            Value::test_record(record! {
424                "country" => Value::test_string("USA"),
425            }),
426        ]);
427
428        assert_eq!(
429            table(
430                value.clone().into_pipeline_data(),
431                false,
432                &Config::default()
433            ),
434            one(r#"
435            |country|
436            |-|
437            |Ecuador|
438            |New Zealand|
439            |USA|
440        "#)
441        );
442
443        assert_eq!(
444            table(value.into_pipeline_data(), true, &Config::default()),
445            one(r#"
446            | country     |
447            | ----------- |
448            | Ecuador     |
449            | New Zealand |
450            | USA         |
451        "#)
452        );
453    }
454
455    #[test]
456    fn test_empty_column_header() {
457        let value = Value::test_list(vec![
458            Value::test_record(record! {
459                "" => Value::test_string("1"),
460                "foo" => Value::test_string("2"),
461            }),
462            Value::test_record(record! {
463                "" => Value::test_string("3"),
464                "foo" => Value::test_string("4"),
465            }),
466        ]);
467
468        assert_eq!(
469            table(
470                value.clone().into_pipeline_data(),
471                false,
472                &Config::default()
473            ),
474            one(r#"
475            ||foo|
476            |-|-|
477            |1|2|
478            |3|4|
479        "#)
480        );
481    }
482
483    #[test]
484    fn test_empty_row_value() {
485        let value = Value::test_list(vec![
486            Value::test_record(record! {
487                "foo" => Value::test_string("1"),
488                "bar" => Value::test_string("2"),
489            }),
490            Value::test_record(record! {
491                "foo" => Value::test_string("3"),
492                "bar" => Value::test_string("4"),
493            }),
494            Value::test_record(record! {
495                "foo" => Value::test_string("5"),
496                "bar" => Value::test_string(""),
497            }),
498        ]);
499
500        assert_eq!(
501            table(
502                value.clone().into_pipeline_data(),
503                false,
504                &Config::default()
505            ),
506            one(r#"
507            |foo|bar|
508            |-|-|
509            |1|2|
510            |3|4|
511            |5||
512        "#)
513        );
514    }
515
516    #[test]
517    fn test_content_type_metadata() {
518        let mut engine_state = Box::new(EngineState::new());
519        let state_delta = {
520            // Base functions that are needed for testing
521            // Try to keep this working set small to keep tests running as fast as possible
522            let mut working_set = StateWorkingSet::new(&engine_state);
523
524            working_set.add_decl(Box::new(ToMd {}));
525            working_set.add_decl(Box::new(Metadata {}));
526            working_set.add_decl(Box::new(Get {}));
527
528            working_set.render()
529        };
530        let delta = state_delta;
531
532        engine_state
533            .merge_delta(delta)
534            .expect("Error merging delta");
535
536        let cmd = "{a: 1 b: 2} | to md  | metadata | get content_type";
537        let result = eval_pipeline_without_terminal_expression(
538            cmd,
539            std::env::temp_dir().as_ref(),
540            &mut engine_state,
541        );
542        assert_eq!(
543            Value::test_record(record!("content_type" => Value::test_string("text/markdown"))),
544            result.expect("There should be a result")
545        );
546    }
547}