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 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 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}