From 7a892ec5d70297a0c1c44955bd370ab0fc09732f Mon Sep 17 00:00:00 2001 From: Luccas Mateus Date: Thu, 9 Dec 2021 22:16:35 -0300 Subject: [PATCH] `To html` and `to md` (#453) * MathEval Variance and Stddev * Fix tests and linting * Typo * Deal with streams when they are not tables * First draft of these commands * To MD * To md and to html * Fixed cargo and to_md * `into_abbreviated_string` instead of `into_string` * Changed how inner tables are displayed --- Cargo.lock | 101 +++ Cargo.toml | 2 + crates/nu-command/Cargo.toml | 4 + crates/nu-command/assets/228_themes.zip | Bin 0 -> 22290 bytes crates/nu-command/src/default_context.rs | 2 + crates/nu-command/src/filters/mod.rs | 4 +- .../src/filters/{zip.rs => zip_.rs} | 0 crates/nu-command/src/formats/to/delimited.rs | 13 +- crates/nu-command/src/formats/to/html.rs | 727 ++++++++++++++++++ crates/nu-command/src/formats/to/md.rs | 443 +++++++++++ crates/nu-command/src/formats/to/mod.rs | 4 + crates/nu-protocol/src/value/mod.rs | 17 +- 12 files changed, 1302 insertions(+), 15 deletions(-) create mode 100644 crates/nu-command/assets/228_themes.zip rename crates/nu-command/src/filters/{zip.rs => zip_.rs} (100%) create mode 100644 crates/nu-command/src/formats/to/html.rs create mode 100644 crates/nu-command/src/formats/to/md.rs diff --git a/Cargo.lock b/Cargo.lock index 2dff56a5da..f3f8291299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,6 +342,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c58ec36aac5066d5ca17df51b3e70279f5670a72102f5752cb7e7c856adfc70" +[[package]] +name = "bzip2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "calamine" version = "0.18.0" @@ -1064,6 +1085,12 @@ dependencies = [ "libc", ] +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + [[package]] name = "ical" version = "0.7.0" @@ -1578,6 +1605,7 @@ dependencies = [ "dtparse", "eml-parser", "glob", + "htmlescape", "ical", "indexmap", "itertools", @@ -1594,10 +1622,12 @@ dependencies = [ "nu-term-grid", "num 0.4.0", "polars", + "pretty-hex", "rand", "rayon", "regex", "roxmltree", + "rust-embed", "serde", "serde_ini", "serde_urlencoded", @@ -1612,6 +1642,7 @@ dependencies = [ "unicode-segmentation", "url", "uuid", + "zip", ] [[package]] @@ -2196,6 +2227,12 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty-hex" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5c99d529f0d30937f6f4b8a86d988047327bb88d04d2c4afc356de74722131" + [[package]] name = "pretty_assertions" version = "0.7.2" @@ -2439,6 +2476,39 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rust-embed" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe1fe6aac5d6bb9e1ffd81002340363272a7648234ec7bdfac5ee202cb65523" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed91c41c42ef7bf687384439c312e75e0da9c149b0390889b94de3c7d9d9e66" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a512219132473ab0a77b52077059f1c47ce4af7fbdc94503e9862a34422876d" +dependencies = [ + "walkdir", +] + [[package]] name = "rust_decimal" version = "0.10.2" @@ -2463,6 +2533,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -3064,6 +3143,17 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -3156,6 +3246,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3190,9 +3289,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93ab48844d61251bb3835145c521d88aa4031d7139e8485990f60ca911fa0815" dependencies = [ "byteorder", + "bzip2", "crc32fast", "flate2", "thiserror", + "time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3d47a4f10b..c6e115b5d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,8 @@ example = ["nu_plugin_example"] # Extra gstat = ["nu_plugin_gstat"] +zip-support = ["nu-command/zip"] + # Dataframe feature for nushell dataframe = ["nu-command/dataframe"] diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index 68a76f04b7..9d78c3cd7e 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -46,9 +46,13 @@ ical = "0.7.0" calamine = "0.18.0" roxmltree = "0.14.0" rand = "0.8" +rust-embed = "5.9.0" trash = { version = "1.3.0", optional = true } unicode-segmentation = "1.8.0" uuid = { version = "0.8.2", features = ["v4"] } +htmlescape = "0.3.1" +pretty-hex = "0.2.1" +zip = { version="0.5.9", optional=true } lazy_static = "1.4.0" strip-ansi-escapes = "0.1.1" crossterm = "0.22.1" diff --git a/crates/nu-command/assets/228_themes.zip b/crates/nu-command/assets/228_themes.zip new file mode 100644 index 0000000000000000000000000000000000000000..eca629d50c5ba9be44baee239a91824bbd56fb8c GIT binary patch literal 22290 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 literal 0 HcmV?d00001 diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 409993925f..31f5367323 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -164,6 +164,8 @@ pub fn create_default_context() -> EngineState { ToToml, ToTsv, ToCsv, + ToHtml, + ToMd, Touch, Uniq, Use, diff --git a/crates/nu-command/src/filters/mod.rs b/crates/nu-command/src/filters/mod.rs index f9a8a7b09f..f2bf1cd345 100644 --- a/crates/nu-command/src/filters/mod.rs +++ b/crates/nu-command/src/filters/mod.rs @@ -22,7 +22,7 @@ mod uniq; mod update; mod where_; mod wrap; -mod zip; +mod zip_; pub use all::All; pub use any::Any; @@ -48,4 +48,4 @@ pub use uniq::*; pub use update::Update; pub use where_::Where; pub use wrap::Wrap; -pub use zip::Zip; +pub use zip_::Zip; diff --git a/crates/nu-command/src/filters/zip.rs b/crates/nu-command/src/filters/zip_.rs similarity index 100% rename from crates/nu-command/src/filters/zip.rs rename to crates/nu-command/src/filters/zip_.rs diff --git a/crates/nu-command/src/formats/to/delimited.rs b/crates/nu-command/src/formats/to/delimited.rs index 6ba5dc1555..1103ecf006 100644 --- a/crates/nu-command/src/formats/to/delimited.rs +++ b/crates/nu-command/src/formats/to/delimited.rs @@ -87,14 +87,11 @@ fn to_string_tagged_value(v: &Value, config: &Config) -> Result Ok(v.clone().into_string("", config)), + | Value::List { .. } + | Value::Record { .. } + | Value::Float { .. } => Ok(v.clone().into_abbreviated_string(config)), Value::Date { val, .. } => Ok(val.to_string()), Value::Nothing { .. } => Ok(String::new()), - Value::List { ref vals, .. } => match &vals[..] { - [Value::Record { .. }, _end @ ..] => Ok(String::from("[Table]")), - _ => Ok(String::from("[List]")), - }, - Value::Record { .. } => Ok(String::from("[Row]")), _ => Err(ShellError::UnsupportedInput( "Unexpected value".to_string(), v.span().unwrap_or_else(|_| Span::unknown()), @@ -102,13 +99,13 @@ fn to_string_tagged_value(v: &Value, config: &Config) -> Result Vec { +pub fn merge_descriptors(values: &[Value]) -> Vec { let mut ret: Vec = vec![]; let mut seen: IndexSet = indexset! {}; for value in values { let data_descriptors = match value { Value::Record { cols, .. } => cols.to_owned(), - _ => vec![], + _ => vec!["".to_string()], }; for desc in data_descriptors { if !seen.contains(&desc) { diff --git a/crates/nu-command/src/formats/to/html.rs b/crates/nu-command/src/formats/to/html.rs new file mode 100644 index 0000000000..90bd977f96 --- /dev/null +++ b/crates/nu-command/src/formats/to/html.rs @@ -0,0 +1,727 @@ +use crate::formats::to::delimited::merge_descriptors; +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Config, Example, IntoPipelineData, PipelineData, ShellError, Signature, Spanned, + SyntaxShape, Value, +}; +use regex::Regex; +use rust_embed::RustEmbed; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::error::Error; +use std::fmt::Write; + +#[derive(Serialize, Deserialize, Debug)] +pub struct HtmlThemes { + themes: Vec, +} + +#[allow(non_snake_case)] +#[derive(Serialize, Deserialize, Debug)] +pub struct HtmlTheme { + name: String, + black: String, + red: String, + green: String, + yellow: String, + blue: String, + purple: String, + cyan: String, + white: String, + brightBlack: String, + brightRed: String, + brightGreen: String, + brightYellow: String, + brightBlue: String, + brightPurple: String, + brightCyan: String, + brightWhite: String, + background: String, + foreground: String, +} + +impl Default for HtmlThemes { + fn default() -> Self { + HtmlThemes { + themes: vec![HtmlTheme::default()], + } + } +} + +impl Default for HtmlTheme { + fn default() -> Self { + HtmlTheme { + name: "nu_default".to_string(), + black: "black".to_string(), + red: "red".to_string(), + green: "green".to_string(), + yellow: "#717100".to_string(), + blue: "blue".to_string(), + purple: "#c800c8".to_string(), + cyan: "#037979".to_string(), + white: "white".to_string(), + brightBlack: "black".to_string(), + brightRed: "red".to_string(), + brightGreen: "green".to_string(), + brightYellow: "#717100".to_string(), + brightBlue: "blue".to_string(), + brightPurple: "#c800c8".to_string(), + brightCyan: "#037979".to_string(), + brightWhite: "white".to_string(), + background: "white".to_string(), + foreground: "black".to_string(), + } + } +} + +#[derive(RustEmbed)] +#[folder = "assets/"] +struct Assets; + +#[derive(Clone)] +pub struct ToHtml; + +impl Command for ToHtml { + fn name(&self) -> &str { + "to html" + } + + fn signature(&self) -> Signature { + Signature::build("to html") + .switch("html_color", "change ansi colors to html colors", Some('c')) + .switch("no_color", "remove all ansi colors in output", Some('n')) + .switch( + "dark", + "indicate your background color is a darker color", + Some('d'), + ) + .switch( + "partial", + "only output the html for the content itself", + Some('p'), + ) + .named( + "theme", + SyntaxShape::String, + "the name of the theme to use (github, blulocolight, ...)", + Some('t'), + ) + .switch("list", "list the names of all available themes", Some('l')) + .category(Category::Formats) + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Outputs an HTML string representing the contents of this table", + example: "[[foo bar]; [1 2]] | to html", + result: Some(Value::test_string( + r#"
foobar
12
"#, + )), + }, + Example { + description: "Optionally, only output the html for the content itself", + example: "[[foo bar]; [1 2]] | to html --partial", + result: Some(Value::test_string( + r#"
foobar
12
"#, + )), + }, + Example { + description: "Optionally, output the string with a dark background", + example: "[[foo bar]; [1 2]] | to html --dark", + result: Some(Value::test_string( + r#"
foobar
12
"#, + )), + }, + ] + } + + fn usage(&self) -> &str { + "Convert table into simple HTML" + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + to_html(input, call, engine_state, stack) + } +} + +fn get_theme_from_asset_file( + is_dark: bool, + theme: &Option>, +) -> Result, ShellError> { + let theme_name = match theme { + Some(s) => s.item.clone(), + None => "default".to_string(), // There is no theme named "default" so this will be HtmlTheme::default(), which is "nu_default". + }; + + // 228 themes come from + // https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/windowsterminal + // we should find a hit on any name in there + let asset = get_asset_by_name_as_html_themes("228_themes.zip", "228_themes.json"); + + // If asset doesn't work, make sure to return the default theme + let asset = match asset { + Ok(a) => a, + _ => HtmlThemes::default(), + }; + + // Find the theme by theme name + let th = asset + .themes + .iter() + .find(|&n| n.name.to_lowercase() == theme_name.to_lowercase()); // case insensitive search + + // If no theme is found by the name provided, ensure we return the default theme + let default_theme = HtmlTheme::default(); + let th = match th { + Some(t) => t, + None => &default_theme, + }; + + // this just means no theme was passed in + if th.name.to_lowercase().eq(&"nu_default".to_string()) + // this means there was a theme passed in + && theme.is_some() + { + return Err(ShellError::NotFound( + theme.as_ref().expect("this should never trigger").span, + )); + } + + Ok(convert_html_theme_to_hash_map(is_dark, th)) +} + +#[allow(unused_variables)] +fn get_asset_by_name_as_html_themes( + zip_name: &str, + json_name: &str, +) -> Result> { + match Assets::get(zip_name) { + Some(content) => { + let asset: Vec = match content { + Cow::Borrowed(bytes) => bytes.into(), + Cow::Owned(bytes) => bytes, + }; + let reader = std::io::Cursor::new(asset); + #[cfg(feature = "zip")] + { + use std::io::Read; + let mut archive = zip::ZipArchive::new(reader)?; + let mut zip_file = archive.by_name(json_name)?; + let mut contents = String::new(); + zip_file.read_to_string(&mut contents)?; + Ok(nu_json::from_str(&contents)?) + } + #[cfg(not(feature = "zip"))] + { + let th = HtmlThemes::default(); + Ok(th) + } + } + None => { + let th = HtmlThemes::default(); + Ok(th) + } + } +} + +fn convert_html_theme_to_hash_map( + is_dark: bool, + theme: &HtmlTheme, +) -> HashMap<&'static str, String> { + let mut hm: HashMap<&str, String> = HashMap::new(); + + hm.insert("bold_black", theme.brightBlack[..].to_string()); + hm.insert("bold_red", theme.brightRed[..].to_string()); + hm.insert("bold_green", theme.brightGreen[..].to_string()); + hm.insert("bold_yellow", theme.brightYellow[..].to_string()); + hm.insert("bold_blue", theme.brightBlue[..].to_string()); + hm.insert("bold_magenta", theme.brightPurple[..].to_string()); + hm.insert("bold_cyan", theme.brightCyan[..].to_string()); + hm.insert("bold_white", theme.brightWhite[..].to_string()); + + hm.insert("black", theme.black[..].to_string()); + hm.insert("red", theme.red[..].to_string()); + hm.insert("green", theme.green[..].to_string()); + hm.insert("yellow", theme.yellow[..].to_string()); + hm.insert("blue", theme.blue[..].to_string()); + hm.insert("magenta", theme.purple[..].to_string()); + hm.insert("cyan", theme.cyan[..].to_string()); + hm.insert("white", theme.white[..].to_string()); + + // Try to make theme work with light or dark but + // flipping the foreground and background but leave + // the other colors the same. + if is_dark { + hm.insert("background", theme.black[..].to_string()); + hm.insert("foreground", theme.white[..].to_string()); + } else { + hm.insert("background", theme.white[..].to_string()); + hm.insert("foreground", theme.black[..].to_string()); + } + + hm +} + +fn get_list_of_theme_names() -> Vec { + let asset = get_asset_by_name_as_html_themes("228_themes.zip", "228_themes.json"); + + // If asset doesn't work, make sure to return the default theme + let html_themes = match asset { + Ok(a) => a, + _ => HtmlThemes::default(), + }; + + let theme_names: Vec = html_themes.themes.iter().map(|n| n.name.clone()).collect(); + + theme_names +} + +fn to_html( + input: PipelineData, + call: &Call, + engine_state: &EngineState, + stack: &mut Stack, +) -> Result { + let head = call.head; + let html_color = call.has_flag("html_color"); + let no_color = call.has_flag("no_color"); + let dark = call.has_flag("dark"); + let partial = call.has_flag("partial"); + let list = call.has_flag("list"); + let theme: Option> = call.get_flag(engine_state, stack, "theme")?; + let config = stack.get_config()?; + + let vec_of_values = input.into_iter().collect::>(); + let headers = merge_descriptors(&vec_of_values); + let headers = Some(headers) + .filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty())); + let mut output_string = String::new(); + let mut regex_hm: HashMap = HashMap::new(); + + if list { + // Get the list of theme names + let theme_names = get_list_of_theme_names(); + + // Put that list into the output string + for s in &theme_names { + writeln!(&mut output_string, "{}", s).unwrap(); + } + + output_string.push_str("\nScreenshots of themes can be found here:\n"); + output_string.push_str("https://github.com/mbadolato/iTerm2-Color-Schemes\n"); + } else { + let theme_span = match &theme { + Some(v) => v.span, + None => head, + }; + + let color_hm = get_theme_from_asset_file(dark, &theme); + let color_hm = match color_hm { + Ok(c) => c, + _ => { + return Err(ShellError::SpannedLabeledError( + "Error finding theme name".to_string(), + "Error finding theme name".to_string(), + theme_span, + )) + } + }; + + // change the color of the page + if !partial { + write!( + &mut output_string, + r"", + color_hm + .get("background") + .expect("Error getting background color"), + color_hm + .get("foreground") + .expect("Error getting foreground color") + ) + .unwrap(); + } else { + write!( + &mut output_string, + "
", + color_hm + .get("background") + .expect("Error getting background color"), + color_hm + .get("foreground") + .expect("Error getting foreground color") + ) + .unwrap(); + } + + let inner_value = match vec_of_values.len() { + 0 => String::default(), + 1 => match headers { + Some(headers) => html_table(vec_of_values, headers, &config), + None => { + let value = &vec_of_values[0]; + html_value(value.clone(), &config) + } + }, + _ => match headers { + Some(headers) => html_table(vec_of_values, headers, &config), + None => html_list(vec_of_values, &config), + }, + }; + + output_string.push_str(&inner_value); + + if !partial { + output_string.push_str(""); + } else { + output_string.push_str("
") + } + + // Check to see if we want to remove all color or change ansi to html colors + if html_color { + setup_html_color_regexes(&mut regex_hm, &color_hm); + output_string = run_regexes(®ex_hm, &output_string); + } else if no_color { + setup_no_color_regexes(&mut regex_hm); + output_string = run_regexes(®ex_hm, &output_string); + } + } + Ok(Value::string(output_string, head).into_pipeline_data()) +} + +fn html_list(list: Vec, config: &Config) -> String { + let mut output_string = String::new(); + output_string.push_str("
    "); + for value in list { + output_string.push_str("
  1. "); + output_string.push_str(&html_value(value, config)); + output_string.push_str("
  2. "); + } + output_string.push_str("
"); + output_string +} + +fn html_table(table: Vec, headers: Vec, config: &Config) -> String { + let mut output_string = String::new(); + + output_string.push_str(""); + + output_string.push_str(""); + for header in &headers { + output_string.push_str(""); + } + output_string.push_str(""); + + for row in table { + if let Value::Record { span, .. } = row { + output_string.push_str(""); + for header in &headers { + let data = row.get_data_by_key(header); + output_string.push_str(""); + } + output_string.push_str(""); + } + } + output_string.push_str("
"); + output_string.push_str(&htmlescape::encode_minimal(header)); + output_string.push_str("
"); + output_string.push_str(&html_value( + data.unwrap_or_else(|| Value::nothing(span)), + config, + )); + output_string.push_str("
"); + + output_string +} + +fn html_value(value: Value, config: &Config) -> String { + let mut output_string = String::new(); + match value { + Value::Binary { val, .. } => { + let output = pretty_hex::pretty_hex(&val); + output_string.push_str("
");
+            output_string.push_str(&output);
+            output_string.push_str("
"); + } + other => output_string.push_str( + &htmlescape::encode_minimal(&other.into_abbreviated_string(config)) + .replace("\n", "
"), + ), + } + output_string +} + +fn setup_html_color_regexes( + hash: &mut HashMap, + color_hm: &HashMap<&str, String>, +) { + // All the bold colors + hash.insert( + 0, + ( + r"(?P\[0m)(?P[[:alnum:][:space:][:punct:]]*)", + // Reset the text color, normal weight font + format!( + r"$word", + color_hm + .get("foreground") + .expect("Error getting reset text color") + ), + ), + ); + hash.insert( + 1, + ( + // Bold Black + r"(?P\[1;30m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("foreground") + .expect("Error getting bold black text color") + ), + ), + ); + hash.insert( + 2, + ( + // Bold Red + r"(?P
\[1;31m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_red") + .expect("Error getting bold red text color"), + ), + ), + ); + hash.insert( + 3, + ( + // Bold Green + r"(?P\[1;32m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_green") + .expect("Error getting bold green text color"), + ), + ), + ); + hash.insert( + 4, + ( + // Bold Yellow + r"(?P\[1;33m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_yellow") + .expect("Error getting bold yellow text color"), + ), + ), + ); + hash.insert( + 5, + ( + // Bold Blue + r"(?P\[1;34m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_blue") + .expect("Error getting bold blue text color"), + ), + ), + ); + hash.insert( + 6, + ( + // Bold Magenta + r"(?P\[1;35m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_magenta") + .expect("Error getting bold magenta text color"), + ), + ), + ); + hash.insert( + 7, + ( + // Bold Cyan + r"(?P\[1;36m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("bold_cyan") + .expect("Error getting bold cyan text color"), + ), + ), + ); + hash.insert( + 8, + ( + // Bold White + // Let's change this to black since the html background + // is white. White on white = no bueno. + r"(?P\[1;37m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("foreground") + .expect("Error getting bold bold white text color"), + ), + ), + ); + // All the normal colors + hash.insert( + 9, + ( + // Black + r"(?P\[30m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("foreground") + .expect("Error getting black text color"), + ), + ), + ); + hash.insert( + 10, + ( + // Red + r"(?P\[31m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm.get("red").expect("Error getting red text color"), + ), + ), + ); + hash.insert( + 11, + ( + // Green + r"(?P\[32m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("green") + .expect("Error getting green text color"), + ), + ), + ); + hash.insert( + 12, + ( + // Yellow + r"(?P\[33m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("yellow") + .expect("Error getting yellow text color"), + ), + ), + ); + hash.insert( + 13, + ( + // Blue + r"(?P\[34m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm.get("blue").expect("Error getting blue text color"), + ), + ), + ); + hash.insert( + 14, + ( + // Magenta + r"(?P\[35m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("magenta") + .expect("Error getting magenta text color"), + ), + ), + ); + hash.insert( + 15, + ( + // Cyan + r"(?P\[36m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm.get("cyan").expect("Error getting cyan text color"), + ), + ), + ); + hash.insert( + 16, + ( + // White + // Let's change this to black since the html background + // is white. White on white = no bueno. + r"(?P\[37m)(?P[[:alnum:][:space:][:punct:]]*)", + format!( + r"$word", + color_hm + .get("foreground") + .expect("Error getting white text color"), + ), + ), + ); +} + +fn setup_no_color_regexes(hash: &mut HashMap) { + // We can just use one regex here because we're just removing ansi sequences + // and not replacing them with html colors. + // attribution: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python + hash.insert( + 0, + ( + r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])", + r"$name_group_doesnt_exist".to_string(), + ), + ); +} + +fn run_regexes(hash: &HashMap, contents: &str) -> String { + let mut working_string = contents.to_owned(); + let hash_count: u32 = hash.len() as u32; + for n in 0..hash_count { + let value = hash.get(&n).expect("error getting hash at index"); + //println!("{},{}", value.0, value.1); + let re = Regex::new(value.0).expect("problem with color regex"); + let after = re.replace_all(&working_string, &value.1[..]).to_string(); + working_string = after.clone(); + } + working_string +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(ToHtml {}) + } +} diff --git a/crates/nu-command/src/formats/to/md.rs b/crates/nu-command/src/formats/to/md.rs new file mode 100644 index 0000000000..b77a1e9bc8 --- /dev/null +++ b/crates/nu-command/src/formats/to/md.rs @@ -0,0 +1,443 @@ +use crate::formats::to::delimited::merge_descriptors; +use indexmap::map::IndexMap; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Config, Example, IntoPipelineData, PipelineData, ShellError, Signature, Span, Value, +}; + +#[derive(Clone)] +pub struct ToMd; + +impl Command for ToMd { + fn name(&self) -> &str { + "to md" + } + + fn signature(&self) -> Signature { + Signature::build("to md") + .switch( + "pretty", + "Formats the Markdown table to vertically align items", + Some('p'), + ) + .switch( + "per-element", + "treat each row as markdown syntax element", + Some('e'), + ) + .category(Category::Formats) + } + + fn usage(&self) -> &str { + "Convert table into simple Markdown" + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Outputs an MD string representing the contents of this table", + example: "[[foo bar]; [1 2]] | to md", + result: Some(Value::test_string("|foo|bar|\n|-|-|\n|1|2|\n")), + }, + Example { + description: "Optionally, output a formatted markdown string", + example: "[[foo bar]; [1 2]] | to md --pretty", + result: Some(Value::test_string( + "| foo | bar |\n| --- | --- |\n| 1 | 2 |\n", + )), + }, + Example { + description: "Treat each row as a markdown element", + example: r#"[{"H1": "Welcome to Nushell" } [[foo bar]; [1 2]]] | to md --per-element --pretty"#, + result: Some(Value::test_string( + "# Welcome to Nushell\n| foo | bar |\n| --- | --- |\n| 1 | 2 |", + )), + }, + ] + } + + fn run( + &self, + _engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let head = call.head; + let pretty = call.has_flag("pretty"); + let per_element = call.has_flag("per-element"); + let config = stack.get_config()?; + to_md(input, pretty, per_element, config, head) + } +} + +fn to_md( + input: PipelineData, + pretty: bool, + per_element: bool, + config: Config, + head: Span, +) -> Result { + let (grouped_input, single_list) = group_by(input, head, &config); + if per_element || single_list { + return Ok(Value::string( + grouped_input + .into_iter() + .map(move |val| match val { + Value::List { .. } => table(val.into_pipeline_data(), pretty, &config), + other => fragment(other, pretty, &config), + }) + .collect::>() + .join(""), + head, + ) + .into_pipeline_data()); + } + Ok(Value::string(table(grouped_input, pretty, &config), head).into_pipeline_data()) +} + +fn fragment(input: Value, pretty: bool, config: &Config) -> String { + let headers = match input { + Value::Record { ref cols, .. } => cols.to_owned(), + _ => vec![], + }; + 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.into_pipeline_data(), pretty, config), + }; + + out.push_str(&markup); + let data = match input.get_data_by_key(&headers[0]) { + Some(v) => v, + None => input, + }; + out.push_str(&data.into_string("|", config)); + } else if let Value::Record { .. } = input { + out = table(input.into_pipeline_data(), pretty, config) + } else { + out = input.into_string("|", config) + } + + out.push('\n'); + out +} + +fn collect_headers(headers: &[String]) -> (Vec, Vec) { + let mut escaped_headers: Vec = Vec::new(); + let mut column_widths: Vec = Vec::new(); + + if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) { + for header in headers { + let escaped_header_string = htmlescape::encode_minimal(header); + column_widths.push(escaped_header_string.len()); + escaped_headers.push(escaped_header_string); + } + } else { + column_widths = vec![0; headers.len()] + } + + (escaped_headers, column_widths) +} + +fn table(input: PipelineData, pretty: bool, config: &Config) -> String { + let vec_of_values = input.into_iter().collect::>(); + let headers = merge_descriptors(&vec_of_values); + + let (escaped_headers, mut column_widths) = collect_headers(&headers); + + let mut escaped_rows: Vec> = Vec::new(); + + for row in vec_of_values { + let mut escaped_row: Vec = Vec::new(); + + match row.to_owned() { + Value::Record { span, .. } => { + for i in 0..headers.len() { + let data = row.get_data_by_key(&headers[i]); + let value_string = data + .unwrap_or_else(|| Value::nothing(span)) + .into_string("|", config); + let new_column_width = value_string.len(); + + escaped_row.push(value_string); + + if column_widths[i] < new_column_width { + column_widths[i] = new_column_width; + } + } + } + p => { + let value_string = htmlescape::encode_minimal(&p.into_abbreviated_string(config)); + escaped_row.push(value_string); + } + } + + escaped_rows.push(escaped_row); + } + + let output_string = if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0)) + && escaped_rows.is_empty() + { + String::from("") + } else { + get_output_string(&escaped_headers, &escaped_rows, &column_widths, pretty) + .trim() + .to_string() + }; + + output_string +} + +pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) { + let mut lists = IndexMap::new(); + let mut single_list = false; + for val in values { + if let Value::Record { ref cols, .. } = val { + lists + .entry(cols.concat()) + .and_modify(|v: &mut Vec| v.push(val.clone())) + .or_insert_with(|| vec![val.clone()]); + } else { + lists + .entry(val.clone().into_string(",", config)) + .and_modify(|v: &mut Vec| v.push(val.clone())) + .or_insert_with(|| vec![val.clone()]); + } + } + let mut output = vec![]; + for (_, mut value) in lists { + if value.len() == 1 { + output.push(value.pop().unwrap_or_else(|| Value::nothing(head))) + } else { + output.push(Value::List { + vals: value.to_vec(), + span: head, + }) + } + } + if output.len() == 1 { + single_list = true; + } + ( + Value::List { + vals: output, + span: head, + } + .into_pipeline_data(), + single_list, + ) +} + +fn get_output_string( + headers: &[String], + rows: &[Vec], + column_widths: &[usize], + pretty: bool, +) -> String { + let mut output_string = String::new(); + + if !headers.is_empty() { + output_string.push('|'); + + for i in 0..headers.len() { + if pretty { + output_string.push(' '); + output_string.push_str(&get_padded_string( + headers[i].clone(), + column_widths[i], + ' ', + )); + output_string.push(' '); + } else { + output_string.push_str(&headers[i]); + } + + output_string.push('|'); + } + + output_string.push_str("\n|"); + + #[allow(clippy::needless_range_loop)] + for i in 0..headers.len() { + if pretty { + output_string.push(' '); + output_string.push_str(&get_padded_string( + String::from("-"), + column_widths[i], + '-', + )); + output_string.push(' '); + } else { + output_string.push('-'); + } + + output_string.push('|'); + } + + output_string.push('\n'); + } + + for row in rows { + if !headers.is_empty() { + output_string.push('|'); + } + + for i in 0..row.len() { + if pretty { + output_string.push(' '); + output_string.push_str(&get_padded_string(row[i].clone(), column_widths[i], ' ')); + output_string.push(' '); + } else { + output_string.push_str(&row[i]); + } + + if !headers.is_empty() { + output_string.push('|'); + } + } + + output_string.push('\n'); + } + + output_string +} + +fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String { + let repeat_length = if text.len() > desired_length { + 0 + } else { + desired_length - text.len() + }; + + format!( + "{}{}", + text, + padding_character.to_string().repeat(repeat_length) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use nu_protocol::{Config, IntoPipelineData, Span, Value}; + + fn one(string: &str) -> String { + string + .lines() + .skip(1) + .map(|line| line.trim()) + .collect::>() + .join("\n") + .trim_end() + .to_string() + } + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(ToMd {}) + } + + #[test] + fn render_h1() { + let value = Value::Record { + cols: vec!["H1".to_string()], + vals: vec![Value::test_string("Ecuador")], + span: Span::unknown(), + }; + + assert_eq!(fragment(value, false, &Config::default()), "# Ecuador\n"); + } + + #[test] + fn render_h2() { + let value = Value::Record { + cols: vec!["H2".to_string()], + vals: vec![Value::test_string("Ecuador")], + span: Span::unknown(), + }; + + assert_eq!(fragment(value, false, &Config::default()), "## Ecuador\n"); + } + + #[test] + fn render_h3() { + let value = Value::Record { + cols: vec!["H3".to_string()], + vals: vec![Value::test_string("Ecuador")], + span: Span::unknown(), + }; + + assert_eq!(fragment(value, false, &Config::default()), "### Ecuador\n"); + } + + #[test] + fn render_blockquote() { + let value = Value::Record { + cols: vec!["BLOCKQUOTE".to_string()], + vals: vec![Value::test_string("Ecuador")], + span: Span::unknown(), + }; + + assert_eq!(fragment(value, false, &Config::default()), "> Ecuador\n"); + } + + #[test] + fn render_table() { + let value = Value::List { + vals: vec![ + Value::Record { + cols: vec!["country".to_string()], + vals: vec![Value::test_string("Ecuador")], + span: Span::unknown(), + }, + Value::Record { + cols: vec!["country".to_string()], + vals: vec![Value::test_string("New Zealand")], + span: Span::unknown(), + }, + Value::Record { + cols: vec!["country".to_string()], + vals: vec![Value::test_string("USA")], + span: Span::unknown(), + }, + ], + span: Span::unknown(), + }; + + assert_eq!( + table( + value.clone().into_pipeline_data(), + false, + &Config::default() + ), + one(r#" + |country| + |-| + |Ecuador| + |New Zealand| + |USA| + "#) + ); + + assert_eq!( + table(value.clone().into_pipeline_data(), true, &Config::default()), + one(r#" + | country | + | ----------- | + | Ecuador | + | New Zealand | + | USA | + "#) + ); + } +} diff --git a/crates/nu-command/src/formats/to/mod.rs b/crates/nu-command/src/formats/to/mod.rs index 18209d3070..22de63f0af 100644 --- a/crates/nu-command/src/formats/to/mod.rs +++ b/crates/nu-command/src/formats/to/mod.rs @@ -1,7 +1,9 @@ mod command; mod csv; mod delimited; +mod html; mod json; +mod md; mod toml; mod tsv; mod url; @@ -10,5 +12,7 @@ pub use self::csv::ToCsv; pub use self::toml::ToToml; pub use self::url::ToUrl; pub use command::To; +pub use html::ToHtml; pub use json::ToJson; +pub use md::ToMd; pub use tsv::ToTsv; diff --git a/crates/nu-protocol/src/value/mod.rs b/crates/nu-protocol/src/value/mod.rs index a64ac7e20a..daa1910040 100644 --- a/crates/nu-protocol/src/value/mod.rs +++ b/crates/nu-protocol/src/value/mod.rs @@ -391,11 +391,18 @@ impl Value { ) } Value::String { val, .. } => val, - Value::List { vals: val, .. } => format!( - "[list {} item{}]", - val.len(), - if val.len() == 1 { "" } else { "s" } - ), + Value::List { ref vals, .. } => match &vals[..] { + [Value::Record { .. }, _end @ ..] => format!( + "[table {} row{}]", + vals.len(), + if vals.len() == 1 { "" } else { "s" } + ), + _ => format!( + "[list {} item{}]", + vals.len(), + if vals.len() == 1 { "" } else { "s" } + ), + }, Value::Record { cols, .. } => format!( "{{record {} field{}}}", cols.len(),