diff --git a/Cargo.lock b/Cargo.lock index 4c46b95088..3439686370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,12 @@ dependencies = [ "zip", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cc" version = "1.0.59" @@ -940,6 +946,22 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "crossterm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fcdc3c9cf8ee446222e8ee8691a6d21b563b8fe1a64b1873080db7b5b23cf0" +dependencies = [ + "bitflags", + "crossterm_winapi", + "lazy_static 1.4.0", + "libc", + "mio 0.7.0", + "parking_lot 0.11.0", + "signal-hook", + "winapi 0.3.9", +] + [[package]] name = "crossterm_winapi" version = "0.6.1" @@ -2850,6 +2872,7 @@ dependencies = [ "nu-test-support", "nu-value-ext", "nu_plugin_binaryview", + "nu_plugin_chart", "nu_plugin_fetch", "nu_plugin_from_bson", "nu_plugin_from_sqlite", @@ -3116,7 +3139,7 @@ name = "nu_plugin_binaryview" version = "0.20.0" dependencies = [ "ansi_term 0.12.1", - "crossterm", + "crossterm 0.18.0", "image", "neso", "nu-errors", @@ -3127,6 +3150,21 @@ dependencies = [ "rawkey", ] +[[package]] +name = "nu_plugin_chart" +version = "0.20.0" +dependencies = [ + "crossterm 0.18.0", + "nu-cli", + "nu-data", + "nu-errors", + "nu-plugin", + "nu-protocol", + "nu-source", + "nu-value-ext", + "tui", +] + [[package]] name = "nu_plugin_fetch" version = "0.20.0" @@ -5471,6 +5509,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tui" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2eaeee894a1e9b90f80aa466fe59154fdb471980b5e104d8836fcea309ae17e" +dependencies = [ + "bitflags", + "cassowary", + "crossterm 0.17.8", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typed-arena" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index bc9d170220..8ecfa7c027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ nu-protocol = {version = "0.20.0", path = "./crates/nu-protocol"} nu-source = {version = "0.20.0", path = "./crates/nu-source"} nu-value-ext = {version = "0.20.0", path = "./crates/nu-value-ext"} +nu_plugin_chart = {version = "0.20.0", path = "./crates/nu_plugin_chart", optional = true} nu_plugin_binaryview = {version = "0.20.0", path = "./crates/nu_plugin_binaryview", optional = true} nu_plugin_fetch = {version = "0.20.0", path = "./crates/nu_plugin_fetch", optional = true} nu_plugin_from_bson = {version = "0.20.0", path = "./crates/nu_plugin_from_bson", optional = true} @@ -59,6 +60,16 @@ serde = {version = "1.0.115", features = ["derive"]} toml = "0.5.6" [features] +ctrlc-support = ["nu-cli/ctrlc"] +directories-support = ["nu-cli/directories", "nu-cli/dirs", "nu-data/directories", "nu-data/dirs"] +git-support = ["nu-cli/git2"] +ptree-support = ["nu-cli/ptree"] +rich-benchmark = ["nu-cli/rich-benchmark"] +rustyline-support = ["nu-cli/rustyline-support"] +term-support = ["nu-cli/term"] +uuid-support = ["nu-cli/uuid_crate"] +which-support = ["nu-cli/ichwh", "nu-cli/which"] + default = [ "sys", "ps", @@ -77,38 +88,30 @@ default = [ "fetch", "rich-benchmark", ] -extra = ["default", "binaryview", "tree", "clipboard-cli", "trash-support", "start", "bson", "sqlite", "s3"] +extra = ["default", "binaryview", "tree", "clipboard-cli", "trash-support", "start", "bson", "sqlite", "s3", "chart"] stable = ["default"] -# Default +trace = ["nu-parser/trace"] + +# Stable (Default) inc = ["nu_plugin_inc"] ps = ["nu_plugin_ps"] sys = ["nu_plugin_sys"] textview = ["nu_plugin_textview"] - -# Stable -binaryview = ["nu_plugin_binaryview"] -bson = ["nu_plugin_from_bson", "nu_plugin_to_bson"] fetch = ["nu_plugin_fetch"] match = ["nu_plugin_match"] post = ["nu_plugin_post"] + +# Extra +bson = ["nu_plugin_from_bson", "nu_plugin_to_bson"] +chart = ["nu_plugin_chart"] +binaryview = ["nu_plugin_binaryview"] +clipboard-cli = ["nu-cli/clipboard-cli"] +trash-support = ["nu-cli/trash-support"] +start = ["nu_plugin_start"] +tree = ["nu_plugin_tree"] s3 = ["nu_plugin_s3"] sqlite = ["nu_plugin_from_sqlite", "nu_plugin_to_sqlite"] -start = ["nu_plugin_start"] -trace = ["nu-parser/trace"] -tree = ["nu_plugin_tree"] - -clipboard-cli = ["nu-cli/clipboard-cli"] -ctrlc-support = ["nu-cli/ctrlc"] -directories-support = ["nu-cli/directories", "nu-cli/dirs", "nu-data/directories", "nu-data/dirs"] -git-support = ["nu-cli/git2"] -ptree-support = ["nu-cli/ptree"] -rich-benchmark = ["nu-cli/rich-benchmark"] -rustyline-support = ["nu-cli/rustyline-support"] -term-support = ["nu-cli/term"] -trash-support = ["nu-cli/trash-support"] -uuid-support = ["nu-cli/uuid_crate"] -which-support = ["nu-cli/ichwh", "nu-cli/which"] # Core plugins that ship with `cargo install nu` by default # Currently, Cargo limits us to installing only one binary @@ -170,6 +173,11 @@ name = "nu_plugin_extra_s3" path = "src/plugins/nu_plugin_extra_s3.rs" required-features = ["s3"] +[[bin]] +name = "nu_plugin_extra_chart" +path = "src/plugins/nu_plugin_extra_chart.rs" +required-features = ["chart"] + # Main nu binary [[bin]] name = "nu" diff --git a/crates/nu-cli/src/commands/histogram.rs b/crates/nu-cli/src/commands/histogram.rs index 902f9f33a5..68f04b1f16 100644 --- a/crates/nu-cli/src/commands/histogram.rs +++ b/crates/nu-cli/src/commands/histogram.rs @@ -112,8 +112,9 @@ pub async fn histogram( nu_data::utils::Operation { grouper: Some(Box::new(move |_, _| Ok(String::from("frequencies")))), splitter: Some(splitter(column_grouper)), - format: None, + format: &None, eval: &evaluate_with, + reduction: &nu_data::utils::Reduction::Count, }, &name, )?; @@ -123,17 +124,33 @@ pub async fn histogram( Ok(futures::stream::iter( results - .percentages + .data .table_entries() - .map(move |value| { - let values = value.table_entries().cloned().collect::>(); - let occurrences = values.len(); - - (occurrences, values[occurrences - 1].clone()) - }) + .cloned() .collect::>() .into_iter() - .map(move |(occurrences, value)| { + .zip( + results + .percentages + .table_entries() + .cloned() + .collect::>() + .into_iter(), + ) + .map(move |(counts, percentages)| { + let percentage = percentages + .table_entries() + .cloned() + .last() + .unwrap_or_else(|| { + UntaggedValue::decimal_from_float(0.0, name.span).into_value(&name) + }); + let value = counts + .table_entries() + .cloned() + .last() + .unwrap_or_else(|| UntaggedValue::int(0).into_value(&name)); + let mut fact = TaggedDictBuilder::new(&name); let column_value = labels .get(idx) @@ -147,19 +164,19 @@ pub async fn histogram( .clone(); fact.insert_value(&column.item, column_value); - fact.insert_untagged("occurrences", UntaggedValue::int(occurrences)); + fact.insert_untagged("count", value); - let percentage = format!( + let fmt_percentage = format!( "{}%", // Some(2) < the number of digits // true < group the digits - crate::commands::str_::from::action(&value, &name, Some(2), true)? + crate::commands::str_::from::action(&percentage, &name, Some(2), true)? .as_string()? ); - fact.insert_untagged("percentage", UntaggedValue::string(percentage)); + fact.insert_untagged("percentage", UntaggedValue::string(fmt_percentage)); let string = std::iter::repeat("*") - .take(value.as_u64().map_err(|_| { + .take(percentage.as_u64().map_err(|_| { ShellError::labeled_error("expected a number", "expected a number", &name) })? as usize) .collect::(); diff --git a/crates/nu-cli/tests/commands/histogram.rs b/crates/nu-cli/tests/commands/histogram.rs index 21fe8d0220..a1a4f18c0d 100644 --- a/crates/nu-cli/tests/commands/histogram.rs +++ b/crates/nu-cli/tests/commands/histogram.rs @@ -54,7 +54,7 @@ fn summarizes_by_values() { | get rusty_at | histogram | where value == "Estados Unidos" - | get occurrences + | get count | echo $it "# )); @@ -93,20 +93,19 @@ fn help() { } #[test] -fn occurrences() { +fn count() { let actual = nu!( cwd: ".", pipeline( r#" - echo "[{"bit":1},{"bit":0},{"bit":0},{"bit":0},{"bit":0},{"bit":0},{"bit":0},{"bit":1}]" - | from json + echo [[bit]; [1] [0] [0] [0] [0] [0] [0] [1]] | histogram bit - | sort-by occurrences + | sort-by count | reject frequency | to json "# )); - let bit_json = r#"[{"bit":"1","occurrences":2,"percentage":"33.33%"},{"bit":"0","occurrences":6,"percentage":"100.00%"}]"#; + let bit_json = r#"[{"bit":"1","count":2,"percentage":"33.33%"},{"bit":"0","count":6,"percentage":"100.00%"}]"#; assert_eq!(actual.out, bit_json); } diff --git a/crates/nu-data/src/utils/internal.rs b/crates/nu-data/src/utils/internal.rs index 946ecbec4a..6101e6493d 100644 --- a/crates/nu-data/src/utils/internal.rs +++ b/crates/nu-data/src/utils/internal.rs @@ -1,6 +1,6 @@ #![allow(clippy::type_complexity)] -use crate::value::compute_values; +use crate::value::unsafe_compute_values; use derive_new::new; use nu_errors::ShellError; use nu_protocol::hir::Operator; @@ -23,6 +23,14 @@ impl Labels { } } + pub fn at_split(&self, idx: usize) -> Option<&str> { + if let Some(k) = self.y.get(idx) { + Some(&k[..]) + } else { + None + } + } + pub fn grouped(&self) -> impl Iterator { self.x.iter() } @@ -51,7 +59,7 @@ fn formula( calculator: Box) -> Result + Send + Sync + 'static>, ) -> Box) -> Result + Send + Sync + 'static> { Box::new(move |acc, datax| -> Result { - let result = match compute_values(Operator::Multiply, &acc, &acc_begin) { + let result = match unsafe_compute_values(Operator::Multiply, &acc, &acc_begin) { Ok(v) => v.into_untagged_value(), Err((left_type, right_type)) => { return Err(ShellError::coerce_error( @@ -62,43 +70,100 @@ fn formula( }; match calculator(datax) { - Ok(total) => Ok(match compute_values(Operator::Plus, &result, &total) { - Ok(v) => v.into_untagged_value(), - Err((left_type, right_type)) => { - return Err(ShellError::coerce_error( - left_type.spanned_unknown(), - right_type.spanned_unknown(), - )) - } - }), + Ok(total) => Ok( + match unsafe_compute_values(Operator::Plus, &result, &total) { + Ok(v) => v.into_untagged_value(), + Err((left_type, right_type)) => { + return Err(ShellError::coerce_error( + left_type.spanned_unknown(), + right_type.spanned_unknown(), + )) + } + }, + ), Err(reason) => Err(reason), } }) } pub fn reducer_for( - command: Reduction, + command: &Reduction, ) -> Box) -> Result + Send + Sync + 'static> { match command { Reduction::Accumulate => Box::new(formula( UntaggedValue::int(1).into_untagged_value(), Box::new(sum), )), - _ => Box::new(formula( + Reduction::Count => Box::new(formula( UntaggedValue::int(0).into_untagged_value(), Box::new(sum), )), } } -pub fn max(values: &Value, tag: impl Into) -> Result<&Value, ShellError> { +pub fn max(values: &Value, tag: impl Into) -> Result { let tag = tag.into(); - values - .table_entries() - .filter_map(|dataset| dataset.table_entries().max()) - .max() - .ok_or_else(|| ShellError::labeled_error("err", "err", &tag)) + let mut x = UntaggedValue::int(0); + + for split in values.table_entries() { + match split.value { + UntaggedValue::Table(ref values) => { + let inner = inner_max(values)?; + + if let Ok(greater_than) = + crate::value::compare_values(Operator::GreaterThan, &inner.value, &x) + { + if greater_than { + x = inner.value.clone(); + } + } else { + return Err(ShellError::unexpected(format!( + "Could not compare\nleft: {:?}\nright: {:?}", + inner.value, x + ))); + } + } + _ => { + return Err(ShellError::labeled_error( + "Attempted to compute the sum of a value that cannot be summed.", + "value appears here", + split.tag.span, + )) + } + } + } + + Ok(x.into_value(&tag)) +} + +pub fn inner_max(data: &[Value]) -> Result { + let mut biggest = data + .first() + .ok_or_else(|| { + ShellError::unexpected("Cannot perform aggregate math operation on empty data") + })? + .value + .clone(); + + for value in data.iter() { + if let Ok(greater_than) = + crate::value::compare_values(Operator::GreaterThan, &value.value, &biggest) + { + if greater_than { + biggest = value.value.clone(); + } + } else { + return Err(ShellError::unexpected(format!( + "Could not compare\nleft: {:?}\nright: {:?}", + biggest, value.value + ))); + } + } + Ok(Value { + value: biggest, + tag: Tag::unknown(), + }) } pub fn sum(data: Vec<&Value>) -> Result { @@ -107,7 +172,7 @@ pub fn sum(data: Vec<&Value>) -> Result { for value in data { match value.value { UntaggedValue::Primitive(_) => { - acc = match compute_values(Operator::Plus, &acc, &value) { + acc = match unsafe_compute_values(Operator::Plus, &acc, &value) { Ok(v) => v, Err((left_type, right_type)) => { return Err(ShellError::coerce_error( @@ -133,19 +198,12 @@ pub fn sort_columns( values: &[String], format: &Option Result>>, ) -> Result, ShellError> { - let mut keys = vec![]; + let mut keys = values.to_vec(); - if let Some(fmt) = format { - for k in values.iter() { - let k = k.clone().tagged_unknown(); - let v = crate::value::Date::naive_from_str(k.borrow_tagged())?.into_untagged_value(); - keys.push(fmt(&v, k.to_string())?); - } - } else { - keys = values.to_vec(); + if format.is_none() { + keys.sort(); } - keys.sort(); Ok(keys) } @@ -167,17 +225,13 @@ pub fn sort(planes: &Labels, values: &Value, tag: impl Into) -> Result>()); + y.push(grouped); } else { - let empty = UntaggedValue::table(&[]).into_value(&tag); - y.push(empty.table_entries().cloned().collect::>()); + y.push(UntaggedValue::Table(vec![]).into_value(&tag)); } } - x.push( - UntaggedValue::table(&y.iter().cloned().flatten().collect::>()) - .into_value(&tag), - ); + x.push(UntaggedValue::table(&y).into_value(&tag)); } Ok(UntaggedValue::table(&x).into_value(&tag)) @@ -195,17 +249,27 @@ pub fn evaluate( let mut y = vec![]; for (idx, subset) in split.table_entries().enumerate() { - let mut set = vec![]; + if let UntaggedValue::Table(values) = &subset.value { + if let Some(ref evaluator) = evaluator { + let mut evaluations = vec![]; - if let Some(ref evaluator) = evaluator { - let value = evaluator(idx, subset)?; + for set in values.iter() { + evaluations.push(evaluator(idx, set)?); + } - set.push(value); - } else { - set.push(UntaggedValue::int(1).into_value(&tag)); + y.push(UntaggedValue::Table(evaluations).into_value(&tag)); + } else { + y.push( + UntaggedValue::Table( + values + .iter() + .map(|_| UntaggedValue::int(1).into_value(&tag)) + .collect::>(), + ) + .into_value(&tag), + ); + } } - - y.push(UntaggedValue::table(&set).into_value(&tag)); } x.push(UntaggedValue::table(&y).into_value(&tag)); @@ -215,14 +279,17 @@ pub fn evaluate( } pub enum Reduction { - #[allow(dead_code)] Count, Accumulate, } -pub fn reduce(values: &Value, tag: impl Into) -> Result { +pub fn reduce( + values: &Value, + reduction_with: &Reduction, + tag: impl Into, +) -> Result { let tag = tag.into(); - let reduce_with = reducer_for(Reduction::Accumulate); + let reduce_with = reducer_for(reduction_with); let mut datasets = vec![]; for dataset in values.table_entries() { @@ -255,8 +322,8 @@ pub fn percentages( .filter_map(|s| { let hundred = UntaggedValue::decimal_from_float(100.0, tag.span); - match compute_values(Operator::Divide, &hundred, &maxima) { - Ok(v) => match compute_values(Operator::Multiply, &s, &v) { + match unsafe_compute_values(Operator::Divide, &hundred, &maxima) { + Ok(v) => match unsafe_compute_values(Operator::Multiply, &s, &v) { Ok(v) => Some(v.into_untagged_value()), Err(_) => None, }, diff --git a/crates/nu-data/src/utils/mod.rs b/crates/nu-data/src/utils/mod.rs index 3f2b364c51..f025b63f69 100644 --- a/crates/nu-data/src/utils/mod.rs +++ b/crates/nu-data/src/utils/mod.rs @@ -6,6 +6,7 @@ mod internal; pub use crate::utils::group::group; pub use crate::utils::split::split; +pub use crate::utils::internal::Reduction; use crate::utils::internal::*; use derive_new::new; @@ -27,8 +28,9 @@ pub struct Model { pub struct Operation<'a> { pub grouper: Option Result + Send>>, pub splitter: Option Result + Send>>, - pub format: Option Result>>, + pub format: &'a Option Result>>, pub eval: &'a Option Result + Send>>, + pub reduction: &'a Reduction, } pub fn report( @@ -46,19 +48,17 @@ pub fn report( .map(|(key, _)| key.clone()) .collect::>(); - let x = if options.format.is_some() { - sort_columns(&x, &options.format) - } else { - sort_columns(&x, &None) - }?; + let x = sort_columns(&x, &options.format)?; let mut y = splitted .row_entries() .map(|(key, _)| key.clone()) .collect::>(); + y.sort(); let planes = Labels { x, y }; + let sorted = sort(&planes, &splitted, &tag)?; let evaluated = evaluate( @@ -72,11 +72,11 @@ pub fn report( )?; let group_labels = planes.grouping_total(); + let split_labels = planes.splits_total(); - let reduced = reduce(&evaluated, &tag)?; + let reduced = reduce(&evaluated, options.reduction, &tag)?; - let max = max(&reduced, &tag)?.clone(); - let maxima = max.clone(); + let maxima = max(&reduced, &tag)?; let percents = percentages(&maxima, &reduced, &tag)?; @@ -89,7 +89,7 @@ pub fn report( }, Range { start: UntaggedValue::int(0).into_untagged_value(), - end: max, + end: split_labels, }, ), data: reduced, @@ -99,7 +99,6 @@ pub fn report( pub mod helpers { use super::Model; - use bigdecimal::BigDecimal; use indexmap::indexmap; use nu_errors::ShellError; use nu_protocol::{UntaggedValue, Value}; @@ -113,10 +112,6 @@ pub mod helpers { UntaggedValue::int(s).into_untagged_value() } - pub fn decimal(f: impl Into) -> Value { - UntaggedValue::decimal(f.into()).into_untagged_value() - } - pub fn decimal_from_float(f: f64, span: Span) -> Value { UntaggedValue::decimal_from_float(f, span).into_untagged_value() } @@ -216,9 +211,12 @@ pub mod helpers { } pub fn date_formatter( - fmt: &'static str, + fmt: String, ) -> Box Result> { - Box::new(move |date: &Value, _: String| date.format(&fmt)) + Box::new(move |date: &Value, _: String| { + let fmt = fmt.clone(); + date.format(&fmt) + }) } pub fn assert_without_checking_percentages(report_a: Model, report_b: Model) { @@ -232,23 +230,24 @@ pub mod helpers { #[cfg(test)] mod tests { use super::helpers::{ - assert_without_checking_percentages, committers, date_formatter, decimal, - decimal_from_float, int, table, + assert_without_checking_percentages, committers, date_formatter, decimal_from_float, int, + table, }; - use super::{report, Labels, Model, Operation, Range}; + use super::{report, Labels, Model, Operation, Range, Reduction}; use nu_errors::ShellError; use nu_protocol::Value; use nu_source::{Span, Tag, TaggedItem}; use nu_value_ext::ValueExt; + #[test] - fn prepares_report_using_accumulating_value() { + fn prepares_report_using_counting_value() { let committers = table(&committers()); let by_date = Box::new(move |_, row: &Value| { let key = String::from("date").tagged_unknown(); let key = row.get_data_by_key(key.borrow_spanned()).unwrap(); - let callback = date_formatter("%Y-%m-%d"); + let callback = date_formatter("%Y-%m-%d".to_string()); callback(&key, "nothing".to_string()) }); @@ -261,7 +260,7 @@ mod tests { let options = Operation { grouper: Some(by_date), splitter: Some(by_country), - format: Some(date_formatter("%Y-%m-%d")), + format: &None, eval: /* value to be used for accumulation */ &Some(Box::new(move |_, value: &Value| { let chickens_key = String::from("chickens").tagged_unknown(); @@ -275,6 +274,7 @@ mod tests { ) }) })), + reduction: &Reduction::Count }; assert_without_checking_percentages( @@ -295,29 +295,29 @@ mod tests { }, Range { start: int(0), - end: int(60), + end: int(3), }, ), data: table(&[ - table(&[int(10), int(30), int(60)]), - table(&[int(5), int(15), int(30)]), - table(&[int(2), int(6), int(12)]), + table(&[int(10), int(20), int(30)]), + table(&[int(5), int(10), int(15)]), + table(&[int(2), int(4), int(6)]), ]), percentages: table(&[ + table(&[ + decimal_from_float(33.33, Span::unknown()), + decimal_from_float(66.66, Span::unknown()), + decimal_from_float(99.99, Span::unknown()), + ]), table(&[ decimal_from_float(16.66, Span::unknown()), - decimal(50), - decimal(100), + decimal_from_float(33.33, Span::unknown()), + decimal_from_float(49.99, Span::unknown()), ]), table(&[ - decimal_from_float(8.33, Span::unknown()), - decimal(25), - decimal(50), - ]), - table(&[ - decimal_from_float(3.33, Span::unknown()), - decimal(10), - decimal(20), + decimal_from_float(6.66, Span::unknown()), + decimal_from_float(13.33, Span::unknown()), + decimal_from_float(19.99, Span::unknown()), ]), ]), }, diff --git a/crates/nu-data/src/utils/split.rs b/crates/nu-data/src/utils/split.rs index 7cdadef7d2..34e8b89f41 100644 --- a/crates/nu-data/src/utils/split.rs +++ b/crates/nu-data/src/utils/split.rs @@ -16,7 +16,7 @@ pub fn split( let mut out = TaggedDictBuilder::new(&tag); if splitter.is_none() { - out.insert_untagged("table", UntaggedValue::table(&[value.clone()])); + out.insert_untagged("table", value.clone()); return Ok(out.into_value()); } diff --git a/crates/nu-protocol/src/value/iter.rs b/crates/nu-protocol/src/value/iter.rs index b1768f7016..b4ad830f72 100644 --- a/crates/nu-protocol/src/value/iter.rs +++ b/crates/nu-protocol/src/value/iter.rs @@ -1,10 +1,12 @@ use crate::value::{UntaggedValue, Value}; +#[derive(Debug)] pub enum RowValueIter<'a> { Empty, Entries(indexmap::map::Iter<'a, String, Value>), } +#[derive(Debug)] pub enum TableValueIter<'a> { Empty, Entries(std::slice::Iter<'a, Value>), diff --git a/crates/nu_plugin_binaryview/Cargo.toml b/crates/nu_plugin_binaryview/Cargo.toml index 256933e9b9..4a72c8b1dc 100644 --- a/crates/nu_plugin_binaryview/Cargo.toml +++ b/crates/nu_plugin_binaryview/Cargo.toml @@ -11,7 +11,7 @@ doctest = false [dependencies] ansi_term = "0.12.1" -crossterm = "0.17" +crossterm = "0.18" image = {version = "0.22.4", default_features = false, features = ["png_codec", "jpeg"]} neso = "0.5.0" nu-errors = {path = "../nu-errors", version = "0.20.0"} diff --git a/crates/nu_plugin_chart/Cargo.toml b/crates/nu_plugin_chart/Cargo.toml new file mode 100644 index 0000000000..a862543d03 --- /dev/null +++ b/crates/nu_plugin_chart/Cargo.toml @@ -0,0 +1,22 @@ +[package] +authors = ["The Nu Project Contributors"] +description = "A plugin to display charts" +edition = "2018" +license = "MIT" +name = "nu_plugin_chart" +version = "0.20.0" + +[lib] +doctest = false + +[dependencies] +nu-data = {path = "../nu-data", version = "0.20.0"} +nu-errors = {path = "../nu-errors", version = "0.20.0"} +nu-plugin = {path = "../nu-plugin", version = "0.20.0"} +nu-protocol = {path = "../nu-protocol", version = "0.20.0"} +nu-source = {path = "../nu-source", version = "0.20.0"} +nu-cli = {path = "../nu-cli", version = "0.20.0"} +nu-value-ext = {path = "../nu-value-ext", version = "0.20.0"} + +crossterm = "0.18" +tui = {version = "0.12.0", default-features = false, features = ["crossterm"]} diff --git a/crates/nu_plugin_chart/src/chart.rs b/crates/nu_plugin_chart/src/chart.rs new file mode 100644 index 0000000000..7fd1f7fdc2 --- /dev/null +++ b/crates/nu_plugin_chart/src/chart.rs @@ -0,0 +1,155 @@ +use nu_errors::ShellError; +use nu_protocol::Value; +use nu_source::Tagged; + +use tui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + widgets::{BarChart as TuiBarChart, Block, Borders}, +}; + +pub enum Columns { + One(Tagged), + Two(Tagged, Tagged), + None, +} + +#[allow(clippy::type_complexity)] +pub struct Chart { + pub reduction: nu_data::utils::Reduction, + pub columns: Columns, + pub eval: Option Result + Send>>, + pub format: Option, +} + +impl Default for Chart { + fn default() -> Self { + Self::new() + } +} + +impl Chart { + pub fn new() -> Chart { + Chart { + reduction: nu_data::utils::Reduction::Count, + columns: Columns::None, + eval: None, + format: None, + } + } +} + +pub struct BarChart<'a> { + pub title: &'a str, + pub data: Vec<(&'a str, u64)>, + pub enhanced_graphics: bool, +} + +impl<'a> BarChart<'a> { + pub fn from_model(model: &'a nu_data::utils::Model) -> Result, ShellError> { + let mut data = Vec::new(); + let mut data_points = Vec::new(); + + for percentages in model + .percentages + .table_entries() + .cloned() + .collect::>() + .into_iter() + { + let mut percentages_collected = vec![]; + + for percentage in percentages + .table_entries() + .cloned() + .collect::>() + .into_iter() + { + percentages_collected.push(percentage.as_u64()?); + } + + data_points.push(percentages_collected); + } + + let mark_in = if model.labels.y.len() <= 1 { + 0 + } else { + (model.labels.y.len() as f64 / 2.0).floor() as usize + }; + + for idx in 0..model.labels.x.len() { + let mut current = 0; + + loop { + let label = if current == mark_in { + model + .labels + .at(idx) + .ok_or_else(|| ShellError::untagged_runtime_error("Could not load data"))? + } else { + "" + }; + + let percentages_collected = data_points + .get(current) + .ok_or_else(|| ShellError::untagged_runtime_error("Could not load data"))?; + + data.push(( + label, + *percentages_collected + .get(idx) + .ok_or_else(|| ShellError::untagged_runtime_error("Could not load data"))?, + )); + + current += 1; + + if current == model.labels.y.len() { + break; + } + } + } + + Ok(BarChart { + title: "Bar Chart", + data: (&data[..]).to_vec(), + enhanced_graphics: true, + }) + } + + pub fn draw(&mut self, ui: &mut tui::Terminal) -> std::io::Result<()> + where + T: tui::backend::Backend, + { + ui.draw(|f| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Percentage(100)].as_ref()) + .split(f.size()); + + let barchart = TuiBarChart::default() + .block(Block::default().title("Chart").borders(Borders::ALL)) + .data(&self.data) + .bar_width(9) + .bar_style(Style::default().fg(Color::Green)) + .value_style( + Style::default() + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(barchart, chunks[0]); + }) + } + + pub fn on_right(&mut self) { + let one_bar = self.data.remove(0); + self.data.push(one_bar); + } + + pub fn on_left(&mut self) { + if let Some(one_bar) = self.data.pop() { + self.data.insert(0, one_bar); + } + } +} diff --git a/crates/nu_plugin_chart/src/lib.rs b/crates/nu_plugin_chart/src/lib.rs new file mode 100644 index 0000000000..f094836813 --- /dev/null +++ b/crates/nu_plugin_chart/src/lib.rs @@ -0,0 +1,4 @@ +mod chart; +mod nu; + +pub use chart::Chart; diff --git a/crates/nu_plugin_chart/src/main.rs b/crates/nu_plugin_chart/src/main.rs new file mode 100644 index 0000000000..f7349207fe --- /dev/null +++ b/crates/nu_plugin_chart/src/main.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::Chart; + +fn main() { + serve_plugin(&mut Chart::new()); +} diff --git a/crates/nu_plugin_chart/src/nu/mod.rs b/crates/nu_plugin_chart/src/nu/mod.rs new file mode 100644 index 0000000000..0b8bdc94ef --- /dev/null +++ b/crates/nu_plugin_chart/src/nu/mod.rs @@ -0,0 +1,330 @@ +use nu_errors::ShellError; +use nu_plugin::Plugin; +use nu_protocol::{CallInfo, ColumnPath, Primitive, Signature, SyntaxShape, UntaggedValue, Value}; +use nu_source::TaggedItem; +use nu_value_ext::ValueExt; + +use crate::chart::{BarChart, Chart, Columns}; + +use std::{ + error::Error, + io::{stdout, Write}, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +use tui::{backend::CrosstermBackend, Terminal}; + +enum Event { + Input(I), + Tick, +} + +fn display(model: &nu_data::utils::Model) -> Result<(), Box> { + let mut app = BarChart::from_model(&model)?; + + enable_raw_mode()?; + + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + + let backend = CrosstermBackend::new(stdout); + + let mut terminal = Terminal::new(backend)?; + + let (tx, rx) = mpsc::channel(); + + let tick_rate = Duration::from_millis(250); + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + if event::poll(tick_rate - last_tick.elapsed()).is_ok() { + if let Ok(CEvent::Key(key)) = event::read() { + let _ = tx.send(Event::Input(key)); + } + } + if last_tick.elapsed() >= tick_rate { + let _ = tx.send(Event::Tick); + last_tick = Instant::now(); + } + } + }); + + terminal.clear()?; + + loop { + app.draw(&mut terminal)?; + + match rx.recv()? { + Event::Input(event) => match event.code { + KeyCode::Char('q') => { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + break; + } + KeyCode::Left => app.on_left(), + KeyCode::Right => app.on_right(), + _ => {} + }, + Event::Tick => {} + } + } + + Ok(()) +} + +impl Plugin for Chart { + fn config(&mut self) -> Result { + Ok(Signature::build("chart") + .desc("Displays bar charts") + .switch("acc", "accumuate values", Some('a')) + .optional( + "columns", + SyntaxShape::Any, + "the columns to chart [x-axis y-axis]", + ) + .named( + "use", + SyntaxShape::ColumnPath, + "column to use for evaluation", + Some('u'), + ) + .named( + "format", + SyntaxShape::String, + "Specify date and time formatting", + Some('f'), + )) + } + + fn sink(&mut self, call_info: CallInfo, input: Vec) { + if let Some(Value { + value: UntaggedValue::Primitive(Primitive::Boolean(true)), + .. + }) = call_info.args.get("acc") + { + self.reduction = nu_data::utils::Reduction::Accumulate; + } + + let _ = self.run(call_info, input); + } +} + +impl Chart { + fn run(&mut self, call_info: CallInfo, input: Vec) -> Result<(), ShellError> { + let args = call_info.args; + let name = call_info.name_tag; + + self.eval = if let Some(path) = args.get("use") { + Some(evaluator(path.as_column_path()?.item)) + } else { + None + }; + + self.format = if let Some(fmt) = args.get("format") { + Some(fmt.as_string()?) + } else { + None + }; + + for arg in args.positional_iter() { + match arg { + Value { + value: UntaggedValue::Primitive(Primitive::String(column)), + tag, + } => { + let column = column.clone(); + self.columns = Columns::One(column.tagged(tag)); + } + Value { + value: UntaggedValue::Table(arguments), + tag, + } => { + if arguments.len() > 1 { + let col1 = arguments + .get(0) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + let col2 = arguments + .get(1) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + self.columns = Columns::Two(col1, col2); + } else { + let col1 = arguments + .get(0) + .ok_or_else(|| { + ShellError::labeled_error( + "expected file and replace strings eg) [find replace]", + "missing find-replace values", + tag, + ) + })? + .as_string()? + .tagged(tag); + + self.columns = Columns::One(col1); + } + } + _ => {} + } + } + + let data = UntaggedValue::table(&input).into_value(&name); + + match &self.columns { + Columns::Two(col1, col2) => { + let key = col1.clone(); + let fmt = self.format.clone(); + + let grouper = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + let fmt = fmt.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => { + if let Some(fmt) = fmt { + let callback = nu_data::utils::helpers::date_formatter(fmt); + callback(&key, "nothing".to_string()) + } else { + nu_value_ext::as_string(&key) + } + } + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let key = col2.clone(); + let splitter = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => nu_value_ext::as_string(&key), + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let formatter = if self.format.is_some() { + let default = String::from("%b-%Y"); + + let string_fmt = self.format.as_ref().unwrap_or_else(|| &default); + + Some(nu_data::utils::helpers::date_formatter( + string_fmt.to_string(), + )) + } else { + None + }; + + let options = nu_data::utils::Operation { + grouper: Some(grouper), + splitter: Some(splitter), + format: &formatter, + eval: &self.eval, + reduction: &self.reduction, + }; + + let _ = display(&nu_data::utils::report(&data, options, &name)?); + } + Columns::One(col) => { + let key = col.clone(); + let fmt = self.format.clone(); + + let grouper = Box::new(move |_: usize, row: &Value| { + let key = key.clone(); + let fmt = fmt.clone(); + + match row.get_data_by_key(key.borrow_spanned()) { + Some(key) => { + if let Some(fmt) = fmt { + let callback = nu_data::utils::helpers::date_formatter(fmt); + callback(&key, "nothing".to_string()) + } else { + nu_value_ext::as_string(&key) + } + } + None => Err(ShellError::labeled_error( + "unknown column", + "unknown column", + key.tag(), + )), + } + }); + + let formatter = if self.format.is_some() { + let default = String::from("%b-%Y"); + + let string_fmt = self.format.as_ref().unwrap_or_else(|| &default); + + Some(nu_data::utils::helpers::date_formatter( + string_fmt.to_string(), + )) + } else { + None + }; + + let options = nu_data::utils::Operation { + grouper: Some(grouper), + splitter: None, + format: &formatter, + eval: &self.eval, + reduction: &self.reduction, + }; + + let _ = display(&nu_data::utils::report(&data, options, &name)?); + } + _ => {} + } + + Ok(()) + } +} + +pub fn evaluator(by: ColumnPath) -> Box Result + Send> { + Box::new(move |_: usize, value: &Value| { + let path = by.clone(); + + let eval = nu_value_ext::get_data_by_column_path(value, &path, move |_, _, error| error); + + match eval { + Ok(with_value) => Ok(with_value), + Err(reason) => Err(reason), + } + }) +} diff --git a/docs/commands/histogram.md b/docs/commands/histogram.md index fb93190f9e..7791b9e6a2 100644 --- a/docs/commands/histogram.md +++ b/docs/commands/histogram.md @@ -35,7 +35,7 @@ If we now want to see how often the different numbers were generated, we can use ```shell > open random_numbers.csv | histogram "random numbers" ───┬────────────────┬─────────────┬────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────── - # │ random numbers │ occurrences │ percentage │ frequency + # │ random numbers │ count │ percentage │ frequency ───┼────────────────┼─────────────┼────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────── 0 │ 0 │ 8 │ 57.14% │ ********************************************************* 1 │ 1 │ 14 │ 100.00% │ **************************************************************************************************** @@ -51,7 +51,7 @@ We can also set the name of the second column or sort the table: ```shell > open random_numbers.csv | histogram "random numbers" probability ───┬────────────────┬─────────────┬────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────── - # │ random numbers │ occurrences │ percentage │ probability + # │ random numbers │ count │ percentage │ probability ───┼────────────────┼─────────────┼────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────── 0 │ 0 │ 8 │ 57.14% │ ********************************************************* 1 │ 1 │ 14 │ 100.00% │ **************************************************************************************************** @@ -66,7 +66,7 @@ We can also set the name of the second column or sort the table: ```shell > open random_numbers.csv | histogram "random numbers" probability | sort-by probability ───┬────────────────┬─────────────┬────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────── - # │ random numbers │ occurrences │ percentage │ probability + # │ random numbers │ count │ percentage │ probability ───┼────────────────┼─────────────┼────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────── 0 │ 4 │ 3 │ 21.43% │ ********************* 1 │ 3 │ 6 │ 42.86% │ ****************************************** @@ -81,9 +81,9 @@ We can also set the name of the second column or sort the table: Of course, histogram operations are not restricted to just analyzing numbers in files, you can also analyze your directories ```shell -> ls -la | histogram type | sort-by occurrences +> ls -la | histogram type | sort-by count ───┬─────────┬─────────────┬────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────── - # │ type │ occurrences │ percentage │ frequency + # │ type │ count │ percentage │ frequency ───┼─────────┼─────────────┼────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────── 0 │ Dir │ 5 │ 4.76% │ **** 1 │ Symlink │ 28 │ 26.67% │ ************************** diff --git a/src/plugins/nu_plugin_extra_chart.rs b/src/plugins/nu_plugin_extra_chart.rs new file mode 100644 index 0000000000..f7349207fe --- /dev/null +++ b/src/plugins/nu_plugin_extra_chart.rs @@ -0,0 +1,6 @@ +use nu_plugin::serve_plugin; +use nu_plugin_chart::Chart; + +fn main() { + serve_plugin(&mut Chart::new()); +}