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 0000000000..eca629d50c Binary files /dev/null and b/crates/nu-command/assets/228_themes.zip differ 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(),