Minimal markdown syntax per element support. (#2997)

This commit is contained in:
Andrés N. Robalino 2021-02-02 12:09:19 -05:00 committed by GitHub
parent c1981dfc26
commit fa928bd25d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 308 additions and 50 deletions

View File

@ -269,7 +269,7 @@ pub(crate) use to::To;
pub(crate) use to_csv::ToCSV; pub(crate) use to_csv::ToCSV;
pub(crate) use to_html::ToHTML; pub(crate) use to_html::ToHTML;
pub(crate) use to_json::ToJSON; pub(crate) use to_json::ToJSON;
pub(crate) use to_md::ToMarkdown; pub(crate) use to_md::Command as ToMarkdown;
pub(crate) use to_toml::ToTOML; pub(crate) use to_toml::ToTOML;
pub(crate) use to_tsv::ToTSV; pub(crate) use to_tsv::ToTSV;
pub(crate) use to_url::ToURL; pub(crate) use to_url::ToURL;
@ -327,6 +327,7 @@ mod tests {
whole_stream_command(StrKebabCase), whole_stream_command(StrKebabCase),
whole_stream_command(StrSnakeCase), whole_stream_command(StrSnakeCase),
whole_stream_command(StrScreamingSnakeCase), whole_stream_command(StrScreamingSnakeCase),
whole_stream_command(ToMarkdown),
] ]
} }

View File

@ -46,13 +46,13 @@ impl WholeStreamCommand for Command {
result: Some(vec![UntaggedValue::row(indexmap! { result: Some(vec![UntaggedValue::row(indexmap! {
"File".to_string() => UntaggedValue::Table(vec![ "File".to_string() => UntaggedValue::Table(vec![
UntaggedValue::row(indexmap! { UntaggedValue::row(indexmap! {
"name".to_string() => UntaggedValue::string("Andrés.txt").into(), "name".to_string() => UntaggedValue::string("Andres.txt").into(),
"type".to_string() => UntaggedValue::string("File").into(), "type".to_string() => UntaggedValue::string("File").into(),
"chickens".to_string() => UntaggedValue::int(10).into(), "chickens".to_string() => UntaggedValue::int(10).into(),
"modified".to_string() => date("2019-07-23".tagged_unknown()).unwrap().into(), "modified".to_string() => date("2019-07-23".tagged_unknown()).unwrap().into(),
}).into(), }).into(),
UntaggedValue::row(indexmap! { UntaggedValue::row(indexmap! {
"name".to_string() => UntaggedValue::string("Andrés.txt").into(), "name".to_string() => UntaggedValue::string("Darren.txt").into(),
"type".to_string() => UntaggedValue::string("File").into(), "type".to_string() => UntaggedValue::string("File").into(),
"chickens".to_string() => UntaggedValue::int(20).into(), "chickens".to_string() => UntaggedValue::int(20).into(),
"modified".to_string() => date("2019-09-24".tagged_unknown()).unwrap().into(), "modified".to_string() => date("2019-09-24".tagged_unknown()).unwrap().into(),

View File

@ -54,7 +54,7 @@ impl WholeStreamCommand for Command {
example: r#"ls | move type --before name | first"#, example: r#"ls | move type --before name | first"#,
result: Some(vec![row! { result: Some(vec![row! {
"type".into() => string("File"), "type".into() => string("File"),
"name".into() => string("Andrés.txt"), "name".into() => string("Andres.txt"),
"chickens".into() => int(10), "chickens".into() => int(10),
"modified".into() => date("2019-07-23") "modified".into() => date("2019-07-23")
}]), }]),
@ -63,7 +63,7 @@ impl WholeStreamCommand for Command {
description: "or move the column \"chickens\" after \"name\"", description: "or move the column \"chickens\" after \"name\"",
example: r#"ls | move chickens --after name | first"#, example: r#"ls | move chickens --after name | first"#,
result: Some(vec![row! { result: Some(vec![row! {
"name".into() => string("Andrés.txt"), "name".into() => string("Andres.txt"),
"chickens".into() => int(10), "chickens".into() => int(10),
"type".into() => string("File"), "type".into() => string("File"),
"modified".into() => date("2019-07-23") "modified".into() => date("2019-07-23")
@ -74,7 +74,7 @@ impl WholeStreamCommand for Command {
example: r#"ls | move name chickens --after type | first"#, example: r#"ls | move name chickens --after type | first"#,
result: Some(vec![row! { result: Some(vec![row! {
"type".into() => string("File"), "type".into() => string("File"),
"name".into() => string("Andrés.txt"), "name".into() => string("Andres.txt"),
"chickens".into() => int(10), "chickens".into() => int(10),
"modified".into() => date("2019-07-23") "modified".into() => date("2019-07-23")
}]), }]),

View File

@ -5,25 +5,33 @@ use nu_engine::WholeStreamCommand;
use nu_errors::ShellError; use nu_errors::ShellError;
use nu_protocol::{ReturnSuccess, Signature, UntaggedValue, Value}; use nu_protocol::{ReturnSuccess, Signature, UntaggedValue, Value};
pub struct ToMarkdown; pub struct Command;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ToMarkdownArgs { pub struct Arguments {
pretty: bool, pretty: bool,
#[serde(rename = "per-element")]
per_element: bool,
} }
#[async_trait] #[async_trait]
impl WholeStreamCommand for ToMarkdown { impl WholeStreamCommand for Command {
fn name(&self) -> &str { fn name(&self) -> &str {
"to md" "to md"
} }
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build("to md").switch( Signature::build("to md")
.switch(
"pretty", "pretty",
"Formats the Markdown table to vertically align items", "Formats the Markdown table to vertically align items",
Some('p'), Some('p'),
) )
.switch(
"per-element",
"treat each row as markdown syntax element",
Some('e'),
)
} }
fn usage(&self) -> &str { fn usage(&self) -> &str {
@ -37,30 +45,116 @@ impl WholeStreamCommand for ToMarkdown {
fn examples(&self) -> Vec<Example> { fn examples(&self) -> Vec<Example> {
vec![ vec![
Example { Example {
description: "Outputs an unformatted md string representing the contents of ls", description: "Outputs an unformatted table markdown string (default)",
example: "ls | to md", example: "ls | to md",
result: None, result: Some(vec![Value::from(one(r#"
|name|type|chickens|modified|
|-|-|-|-|
|Andres.txt|File|10|1 year ago|
|Jonathan|Dir|5|1 year ago|
|Darren.txt|File|20|1 year ago|
|Yehuda|Dir|4|1 year ago|
"#))]),
}, },
Example { Example {
description: "Outputs a formatted md string representing the contents of ls", description: "Optionally, output a formatted markdown string",
example: "ls | to md -p", example: "ls | to md --pretty",
result: None, result: Some(vec![Value::from(one(r#"
| name | type | chickens | modified |
| ---------- | ---- | -------- | ---------- |
| Andres.txt | File | 10 | 1 year ago |
| Jonathan | Dir | 5 | 1 year ago |
| Darren.txt | File | 20 | 1 year ago |
| Yehuda | Dir | 4 | 1 year ago |
"#))]),
}, },
Example {
description: "Treat each row as a markdown element",
example: "echo [[H1]; [\"Welcome to Nushell\"]] | append $(ls | first 2) | to md --per-element --pretty",
result: Some(vec![Value::from(one(r#"
# Welcome to Nushell
| name | type | chickens | modified |
| ---------- | ---- | -------- | ---------- |
| Andres.txt | File | 10 | 1 year ago |
| Jonathan | Dir | 5 | 1 year ago |
"#))]),
}
] ]
} }
} }
async fn to_md(args: CommandArgs) -> Result<OutputStream, ShellError> { async fn to_md(args: CommandArgs) -> Result<OutputStream, ShellError> {
let name_tag = args.call_info.name_tag.clone(); let name_tag = args.call_info.name_tag.clone();
let (ToMarkdownArgs { pretty }, input) = args.process().await?; let (arguments, input) = args.process().await?;
let input: Vec<Value> = input.collect().await;
let headers = nu_protocol::merge_descriptors(&input);
let input: Vec<Value> = input.collect().await;
Ok(OutputStream::one(ReturnSuccess::value(
UntaggedValue::string(process(&input, arguments)).into_value(if input.is_empty() {
name_tag
} else {
input[0].tag()
}),
)))
}
fn process(
input: &[Value],
Arguments {
pretty,
per_element,
}: Arguments,
) -> String {
if per_element {
input
.iter()
.map(|v| match &v.value {
UntaggedValue::Table(values) => table(values, pretty),
_ => fragment(v, pretty),
})
.collect::<String>()
} else {
table(&input, pretty)
}
}
fn fragment(input: &Value, pretty: bool) -> String {
let headers = input.data_descriptors();
let mut out = String::new();
if headers.len() == 1 {
let markup = match (&headers[0]).to_ascii_lowercase().as_ref() {
"h1" => "# ".to_string(),
"h2" => "## ".to_string(),
"h3" => "### ".to_string(),
"blockquote" => "> ".to_string(),
_ => return table(&[input.clone()], pretty),
};
out.push_str(&markup);
out.push_str(&format_leaf(input.get_data(&headers[0]).borrow()).plain_string(100_000));
} else if input.is_row() {
let string = match input.row_entries().next() {
Some(value) => value.1.as_string().unwrap_or_default(),
None => String::from(""),
};
out = format_leaf(&UntaggedValue::from(string)).plain_string(100_000)
} else {
out = format_leaf(&input.value).plain_string(100_000)
}
out.push('\n');
out
}
fn collect_headers(headers: &[String]) -> (Vec<String>, Vec<usize>) {
let mut escaped_headers: Vec<String> = Vec::new(); let mut escaped_headers: Vec<String> = Vec::new();
let mut column_widths: Vec<usize> = Vec::new(); let mut column_widths: Vec<usize> = Vec::new();
if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) { if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
for header in &headers { for header in headers {
let escaped_header_string = htmlescape::encode_minimal(&header); let escaped_header_string = htmlescape::encode_minimal(&header);
column_widths.push(escaped_header_string.len()); column_widths.push(escaped_header_string.len());
escaped_headers.push(escaped_header_string); escaped_headers.push(escaped_header_string);
@ -69,9 +163,17 @@ async fn to_md(args: CommandArgs) -> Result<OutputStream, ShellError> {
column_widths = vec![0; headers.len()] column_widths = vec![0; headers.len()]
} }
(escaped_headers, column_widths)
}
fn table(input: &[Value], pretty: bool) -> String {
let headers = nu_protocol::merge_descriptors(&input);
let (escaped_headers, mut column_widths) = collect_headers(&headers);
let mut escaped_rows: Vec<Vec<String>> = Vec::new(); let mut escaped_rows: Vec<Vec<String>> = Vec::new();
for row in &input { for row in input {
let mut escaped_row: Vec<String> = Vec::new(); let mut escaped_row: Vec<String> = Vec::new();
match row.value.clone() { match row.value.clone() {
@ -108,9 +210,7 @@ async fn to_md(args: CommandArgs) -> Result<OutputStream, ShellError> {
.to_string() .to_string()
}; };
Ok(OutputStream::one(ReturnSuccess::value( output_string
UntaggedValue::string(output_string).into_value(name_tag),
)))
} }
fn get_output_string( fn get_output_string(
@ -201,15 +301,78 @@ fn get_padded_string(text: String, desired_length: usize, padding_character: cha
) )
} }
fn one(string: &str) -> String {
string
.lines()
.skip(1)
.map(|line| line.trim())
.collect::<Vec<&str>>()
.join("\n")
.trim_end()
.to_string()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ShellError; use super::{fragment, one, table};
use super::ToMarkdown; use nu_protocol::{row, Value};
#[test] #[test]
fn examples_work_as_expected() -> Result<(), ShellError> { fn render_h1() {
use crate::examples::test as test_examples; let value = row! {"H1".into() => Value::from("Ecuador")};
Ok(test_examples(ToMarkdown {})?) assert_eq!(fragment(&value, false), "# Ecuador\n");
}
#[test]
fn render_h2() {
let value = row! {"H2".into() => Value::from("Ecuador")};
assert_eq!(fragment(&value, false), "## Ecuador\n");
}
#[test]
fn render_h3() {
let value = row! {"H3".into() => Value::from("Ecuador")};
assert_eq!(fragment(&value, false), "### Ecuador\n");
}
#[test]
fn render_blockquote() {
let value = row! {"BLOCKQUOTE".into() => Value::from("Ecuador")};
assert_eq!(fragment(&value, false), "> Ecuador\n");
}
#[test]
fn render_table() {
let value = vec![
row! { "country".into() => Value::from("Ecuador")},
row! { "country".into() => Value::from("New Zealand")},
row! { "country".into() => Value::from("USA")},
];
assert_eq!(
table(&value, false),
one(r#"
|country|
|-|
|Ecuador|
|New Zealand|
|USA|
"#)
);
assert_eq!(
table(&value, true),
one(r#"
| country |
| ----------- |
| Ecuador |
| New Zealand |
| USA |
"#)
);
} }
} }

View File

@ -16,7 +16,7 @@ use nu_protocol::{ShellTypeName, Value};
use nu_source::AnchorLocation; use nu_source::AnchorLocation;
use crate::commands::{ use crate::commands::{
BuildString, Each, Echo, First, Get, Keep, Last, Let, Nth, StrCollect, Wrap, Append, BuildString, Each, Echo, First, Get, Keep, Last, Let, Nth, Select, StrCollect, Wrap,
}; };
use nu_engine::{run_block, whole_stream_command, Command, EvaluationContext, WholeStreamCommand}; use nu_engine::{run_block, whole_stream_command, Command, EvaluationContext, WholeStreamCommand};
use nu_stream::InputStream; use nu_stream::InputStream;
@ -32,6 +32,7 @@ pub fn test_examples(cmd: Command) -> Result<(), ShellError> {
// Command Doubles // Command Doubles
whole_stream_command(DoubleLs {}), whole_stream_command(DoubleLs {}),
// Minimal restricted commands to aid in testing // Minimal restricted commands to aid in testing
whole_stream_command(Append {}),
whole_stream_command(Echo {}), whole_stream_command(Echo {}),
whole_stream_command(BuildString {}), whole_stream_command(BuildString {}),
whole_stream_command(First {}), whole_stream_command(First {}),
@ -41,6 +42,7 @@ pub fn test_examples(cmd: Command) -> Result<(), ShellError> {
whole_stream_command(Last {}), whole_stream_command(Last {}),
whole_stream_command(Nth {}), whole_stream_command(Nth {}),
whole_stream_command(Let {}), whole_stream_command(Let {}),
whole_stream_command(Select),
whole_stream_command(StrCollect), whole_stream_command(StrCollect),
whole_stream_command(Wrap), whole_stream_command(Wrap),
cmd, cmd,
@ -100,6 +102,7 @@ pub fn test(cmd: impl WholeStreamCommand + 'static) -> Result<(), ShellError> {
whole_stream_command(Each {}), whole_stream_command(Each {}),
whole_stream_command(Let {}), whole_stream_command(Let {}),
whole_stream_command(cmd), whole_stream_command(cmd),
whole_stream_command(Select),
whole_stream_command(StrCollect), whole_stream_command(StrCollect),
whole_stream_command(Wrap), whole_stream_command(Wrap),
]); ]);
@ -153,6 +156,7 @@ pub fn test_anchors(cmd: Command) -> Result<(), ShellError> {
whole_stream_command(StubOpen {}), whole_stream_command(StubOpen {}),
whole_stream_command(DoubleEcho {}), whole_stream_command(DoubleEcho {}),
whole_stream_command(DoubleLs {}), whole_stream_command(DoubleLs {}),
whole_stream_command(Append {}),
whole_stream_command(BuildString {}), whole_stream_command(BuildString {}),
whole_stream_command(First {}), whole_stream_command(First {}),
whole_stream_command(Get {}), whole_stream_command(Get {}),
@ -161,6 +165,7 @@ pub fn test_anchors(cmd: Command) -> Result<(), ShellError> {
whole_stream_command(Last {}), whole_stream_command(Last {}),
whole_stream_command(Nth {}), whole_stream_command(Nth {}),
whole_stream_command(Let {}), whole_stream_command(Let {}),
whole_stream_command(Select),
whole_stream_command(StrCollect), whole_stream_command(StrCollect),
whole_stream_command(Wrap), whole_stream_command(Wrap),
cmd, cmd,
@ -172,6 +177,8 @@ pub fn test_anchors(cmd: Command) -> Result<(), ShellError> {
let mut ctx = base_context.clone(); let mut ctx = base_context.clone();
let block = parse_line(&pipeline_with_anchor, &ctx)?; let block = parse_line(&pipeline_with_anchor, &ctx)?;
if let Some(_) = &sample_pipeline.result {
let result = block_on(evaluate_block(block, &mut ctx))?; let result = block_on(evaluate_block(block, &mut ctx))?;
ctx.with_errors(|reasons| reasons.iter().cloned().take(1).next()) ctx.with_errors(|reasons| reasons.iter().cloned().take(1).next())
@ -190,6 +197,7 @@ pub fn test_anchors(cmd: Command) -> Result<(), ShellError> {
} }
} }
} }
}
Ok(()) Ok(())
} }

View File

@ -7,7 +7,7 @@ pub mod ls {
pub fn file_listing() -> Vec<Value> { pub fn file_listing() -> Vec<Value> {
vec![ vec![
row! { row! {
"name".to_string() => string("Andrés.txt"), "name".to_string() => string("Andres.txt"),
"type".to_string() => string("File"), "type".to_string() => string("File"),
"chickens".to_string() => int(10), "chickens".to_string() => int(10),
"modified".to_string() => date("2019-07-23") "modified".to_string() => date("2019-07-23")
@ -19,7 +19,7 @@ pub mod ls {
"modified".to_string() => date("2019-07-23") "modified".to_string() => date("2019-07-23")
}, },
row! { row! {
"name".to_string() => string("Andrés.txt"), "name".to_string() => string("Darren.txt"),
"type".to_string() => string("File"), "type".to_string() => string("File"),
"chickens".to_string() => int(20), "chickens".to_string() => int(20),
"modified".to_string() => date("2019-09-24") "modified".to_string() => date("2019-09-24")

View File

@ -5,7 +5,7 @@ fn md_empty() {
let actual = nu!( let actual = nu!(
cwd: ".", pipeline( cwd: ".", pipeline(
r#" r#"
echo "{}" | from json | to md echo [[]; []] | from json | to md
"# "#
)); ));
@ -53,7 +53,7 @@ fn md_table() {
let actual = nu!( let actual = nu!(
cwd: ".", pipeline( cwd: ".", pipeline(
r#" r#"
echo '{"name": "jason"}' | from json | to md echo [[name]; [jason]] | to md
"# "#
)); ));
@ -65,9 +65,34 @@ fn md_table_pretty() {
let actual = nu!( let actual = nu!(
cwd: ".", pipeline( cwd: ".", pipeline(
r#" r#"
echo '{"name": "joseph"}' | from json | to md -p echo [[name]; [joseph]] | to md -p
"# "#
)); ));
assert_eq!(actual.out, "| name || ------ || joseph |"); assert_eq!(actual.out, "| name || ------ || joseph |");
} }
#[test]
fn md_combined() {
let actual = nu!(
cwd: ".", pipeline(
r#"
def title [] {
echo [[H1]; ["Nu top meals"]]
};
def meals [] {
echo [[dish]; [Arepa] [Taco] [Pizza]]
};
title
| append $(meals)
| to md --per-element --pretty
"#
));
assert_eq!(
actual.out,
"# Nu top meals| dish || ----- || Arepa || Taco || Pizza |"
);
}

View File

@ -920,6 +920,67 @@ impl DateTimeExt for DateTime<FixedOffset> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use indexmap::indexmap;
#[test]
fn test_merge_descriptors() {
let value = vec![
UntaggedValue::row(indexmap! {
"h1".into() => Value::from("Ecuador")
})
.into_untagged_value(),
UntaggedValue::row(indexmap! {
"h2".into() => Value::from("Ecuador")
})
.into_untagged_value(),
UntaggedValue::row(indexmap! {
"h3".into() => Value::from("Ecuador")
})
.into_untagged_value(),
UntaggedValue::row(indexmap! {
"h1".into() => Value::from("Ecuador"),
"h4".into() => Value::from("Ecuador"),
})
.into_untagged_value(),
];
assert_eq!(
merge_descriptors(&value),
vec![
String::from("h1"),
String::from("h2"),
String::from("h3"),
String::from("h4")
]
);
}
#[test]
fn test_data_descriptors() {
let value = vec![
UntaggedValue::row(indexmap! {
"h1".into() => Value::from("Ecuador")
}),
UntaggedValue::row(indexmap! {
"h2".into() => Value::from("Ecuador")
}),
UntaggedValue::row(indexmap! {
"h3".into() => Value::from("Ecuador")
}),
UntaggedValue::row(indexmap! {
"h1".into() => Value::from("Ecuador"),
"h4".into() => Value::from("Ecuador"),
}),
];
assert_eq!(
value
.iter()
.map(|v| v.data_descriptors().len())
.collect::<Vec<_>>(),
vec![1, 1, 1, 2]
);
}
#[test] #[test]
fn test_decimal_from_float() { fn test_decimal_from_float() {