diff options
author | Canop <cano.petrole@gmail.com> | 2022-03-03 22:16:22 +0100 |
---|---|---|
committer | Canop <cano.petrole@gmail.com> | 2022-03-03 22:16:22 +0100 |
commit | e51128ac7a1391578ff05270741faf609c21884e (patch) | |
tree | 06f9b8bf7a812333ad3f5d507cc268fa420407c6 | |
parent | 09b2d9d9826693c750df139422cf6ee0fff98d35 (diff) |
add the --filter argument
Fix #41
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Cargo.lock | 9 | ||||
-rw-r--r-- | Cargo.toml | 3 | ||||
-rw-r--r-- | bacon.toml | 68 | ||||
-rw-r--r-- | src/args.rs | 5 | ||||
-rw-r--r-- | src/col_expr.rs | 322 | ||||
-rw-r--r-- | src/filter.rs | 80 | ||||
-rw-r--r-- | src/main.rs | 11 | ||||
-rw-r--r-- | src/table.rs | 2 |
9 files changed, 497 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7358..dcb3f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### next - 'unreachable' information available in JSON and in the table (in the 'use' column). This mostly concerns disconnected remote filesystems. +- `--filter` argument to filter the displayed filesystems - Fix #41 <a name="v2.3.1"></a> ### v2.3.1 - 2022/03/01 @@ -56,6 +56,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] +name = "bet" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e218e587658fb0b595e7d27c222a4caf56748a47644bc31483973d8dc6d8670" + +[[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -243,9 +249,10 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "lfs" -version = "2.3.2-dev" +version = "2.4.0-dev" dependencies = [ "argh", + "bet", "crossterm", "file-size", "lfs-core", @@ -1,6 +1,6 @@ [package] name = "lfs" -version = "2.3.2-dev" +version = "2.4.0-dev" authors = ["dystroy <denys.seguret@gmail.com>"] edition = "2021" keywords = ["linux", "filesystem", "fs"] @@ -13,6 +13,7 @@ rust-version = "1.56" [dependencies] argh = "0.1.7" +bet = "1.0.0" crossterm = "0.22.1" file-size = "1.0.3" lfs-core = "0.10.2" diff --git a/bacon.toml b/bacon.toml new file mode 100644 index 0000000..6aa0d59 --- /dev/null +++ b/bacon.toml @@ -0,0 +1,68 @@ +# This is a configuration file for the bacon tool +# More info at https://github.com/Canop/bacon + +default_job = "check" + +[jobs] + +[jobs.check] +command = ["cargo", "check", "--color", "always"] +need_stdout = false + +[jobs.check-all] +command = ["cargo", "check", "--all-targets", "--color", "always"] +need_stdout = false +watch = ["tests", "benches", "examples"] + +[jobs.clippy] +command = [ + "cargo", "clippy", + "--color", "always", + "--", + "-A", "clippy::collapsible_else_if", + "-A", "clippy::collapsible_if", + "-A", "clippy::enum_variant_names", + "-A", "clippy::match_like_matches_macro", +] +need_stdout = false + +[jobs.clippy-all] +command = ["cargo", "clippy", "--all-targets", "--color", "always"] +need_stdout = false +watch = ["tests", "benches", "examples"] + +[jobs.test] +command = ["cargo", "test", "--color", "always"] +need_stdout = true +watch = ["tests"] + +[jobs.doc] +command = ["cargo", "doc", "--color", "always", "--no-deps"] +need_stdout = false + +# if the doc compiles, then it opens in your browser and bacon switches +# to the previous job +[jobs.doc-open] +command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] +need_stdout = false +on_success = "back" # so that we don't open the browser at each change + +# You can run your application and have the result displayed in bacon, +# *if* it makes sense for this crate. You can run an example the same +# way. Don't forget the `--color always` part or the errors won't be +# properly parsed. +[jobs.run] +command = ["cargo", "run", "--color", "always"] +need_stdout = true + +# You may define here keybindings that would be specific to +# a project, for example a shortcut to launch a specific job. +# Shortcuts to internal functions (scrolling, toggling, etc.) +# should go in your personal prefs.toml file instead. +[keybindings] +a = "job:check-all" +i = "job:initial" +c = "job:clippy" +d = "job:doc-open" +t = "job:test" +r = "job:run" diff --git a/src/args.rs b/src/args.rs index 7d9e777..ef2fecd 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,6 +1,7 @@ use { crate::{ cols::Cols, + filter::Filter, units::Units, sorting::Sorting, }, @@ -34,6 +35,10 @@ pub struct Args { #[argh(option, default = "Default::default()", short = 'c')] pub cols: Cols, + /// filter, eg `-f '(size<35G | remote=false) & type=xfs'` + #[argh(option, default = "Default::default()", short = 'f')] + pub filter: Filter, + /// sort, eg `-s inodes` or `-s size-asc` #[argh(option, default = "Default::default()", short = 's')] pub sort: Sorting, diff --git a/src/col_expr.rs b/src/col_expr.rs new file mode 100644 index 0000000..9ff5402 --- /dev/null +++ b/src/col_expr.rs @@ -0,0 +1,322 @@ +use { + crate::{ + col::*, + }, + lfs_core::*, + std::{ + fmt, + str::FromStr, + }, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColOperator { + Lower, + LowerOrEqual, + Like, + Equal, + NotEqual, + GreaterOrEqual, + Greater, +} + +impl ColOperator { + pub fn eval<T: PartialOrd+PartialEq>(self, a: T, b: T) -> bool { + match self { + Self::Lower => a < b, + Self::LowerOrEqual => a <= b, + Self::Equal | Self::Like => a == b, + Self::NotEqual => a != b, + Self::GreaterOrEqual => a >= b, + Self::Greater => a > b, + } + } + pub fn eval_option<T: PartialOrd+PartialEq>(self, a: Option<T>, b: T) -> bool { + match a { + Some(a) => self.eval(a, b), + None => false, + } + } + pub fn eval_str(self, a: &str, b: &str) -> bool { + match self { + Self::Like => a.to_lowercase().contains(&b.to_lowercase()), + _ => self.eval(a, b), + } + } + pub fn eval_option_str(self, a: Option<&str>, b: &str) -> bool { + match (a, self) { + (Some(a), Self::Like) => a.to_lowercase().contains(&b.to_lowercase()), + _ => self.eval_option(a, b), + } + } +} + +/// A leaf in the filter expression tree, an expression which +/// may return true or false for any filesystem +#[derive(Debug, Clone, PartialEq)] +pub struct ColExpr { + col: Col, + operator: ColOperator, + value: String, +} + +impl ColExpr { + #[cfg(test)] + pub fn new<S: Into<String>>(col: Col, operator: ColOperator, value: S) -> Self { + Self { + col, + operator, + value: value.into(), + } + } + pub fn eval(&self, mount: &Mount) -> Result<bool, EvalExprError> { + Ok(match self.col { + Col::Id => self.operator.eval( + mount.info.id, + self.value.parse::<MountId>() + .map_err(|_| EvalExprError::NotAnId(self.value.to_string()))?, + ), + Col::Dev => self.operator.eval( + mount.info.dev, + self.value.parse::<DeviceId>() + .map_err(|_| EvalExprError::NotADeviceId(self.value.to_string()))?, + ), + Col::Filesystem => self.operator.eval_str( + &mount.info.fs, + &self.value, + ), + Col::Label => self.operator.eval_option_str( + mount.fs_label.as_ref().map(|s| s.as_str()), + &self.value, + ), + Col::Type => self.operator.eval_str( + &mount.info.fs_type, + &self.value, + ), + Col::Remote => self.operator.eval( + mount.info.is_remote(), + parse_bool(&self.value), + ), + Col::Disk => self.operator.eval_option_str( + mount.disk.as_ref().map(|d| d.name.as_str()), + &self.value, + ), + Col::Used => self.operator.eval_option( + mount.stats().as_ref().map(|s| s.used()), + parse_integer(&self.value)?, + ), + Col::Use | Col::UsePercent => self.operator.eval_option( + mount.stats().as_ref().map(|s| s.use_share()), + parse_float(&self.value)?, + ), + Col::Free => self.operator.eval_option( + mount.stats().as_ref().map(|s| s.available()), + parse_integer(&self.value)?, + ), + Col::Size => self.operator.eval_option( + mount.stats().as_ref().map(|s| s.size()), + parse_integer(&self.value)?, + ), + Col::InodesUsed => self.operator.eval_option( + mount.inodes().as_ref().map(|i| i.used()), + parse_integer(&self.value)?, + ), + Col::InodesUse | Col::InodesUsePercent => self.operator.eval_option( + mount.inodes().as_ref().map(|i| i.use_share()), + parse_float(&self.value)?, + ), + Col::InodesFree => self.operator.eval_option( + mount.inodes().as_ref().map(|i| i.favail), + parse_integer(&self.value)?, + ), + Col::InodesCount => self.operator.eval_option( + mount.inodes().as_ref().map(|i| i.files), + parse_integer(&self.value)?, + ), + Col::MountPoint => self.operator.eval_str( + &mount.info.mount_point.to_string_lossy().to_string(), + &self.value, + ), + }) + } +} + +#[derive(Debug)] +pub struct ParseExprError { + /// the string which couldn't be parsed + pub raw: String, + /// why + pub message: String, +} +impl ParseExprError { + pub fn new<R: Into<String>, M: Into<String>>(raw: R, message: M) -> Self { + Self { + raw: raw.into(), + message: message.into(), + } + } +} +impl fmt::Display for ParseExprError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:?} can't be parsed as an expression: {}", + self.raw, + self.message + ) + } +} +impl std::error::Error for ParseExprError {} + +impl FromStr for ColExpr { + type Err = ParseExprError; + fn from_str(input: &str) -> Result<Self, ParseExprError> { + let mut chars_indices = input.char_indices(); + let mut op_idx = 0; + for (idx, c) in &mut chars_indices { + if c == '<' || c == '>' || c == '=' { + op_idx = idx; + break; + } + } + if op_idx == 0 { + return Err(ParseExprError::new(input, "Invalid expression; expected <column><operator><value>")); + } + let mut val_idx = op_idx + 1; + for (idx, c) in &mut chars_indices { + if c != '<' && c != '>' && c != '=' { + val_idx = idx; + break; + } + } + if val_idx == input.len() { + return Err(ParseExprError::new(input, "no value")); + } + let col = &input[..op_idx]; + let col = col.parse() + .map_err(|e: ParseColError| ParseExprError::new(input, e.to_string()))?; + let operator = match &input[op_idx..val_idx] { + "<" => ColOperator::Lower, + "<=" => ColOperator::LowerOrEqual, + "=" => ColOperator::Like, + "==" => ColOperator::Equal, + "<>" => ColOperator::NotEqual, + ">=" => ColOperator::GreaterOrEqual, + ">" => ColOperator::Greater, + op => { + return Err(ParseExprError::new( + input, + format!("unknown operator: {:?}", op), + )); + } + }; + let value = &input[val_idx..]; + let value = value.into(); + Ok(Self { col, operator, value }) + } +} + +#[test] +fn test_col_filter_parsing() { + assert_eq!( + "remote=false".parse::<ColExpr>().unwrap(), + ColExpr::new(Col::Remote, ColOperator::Like, "false"), + ); + assert_eq!( + "size<32G".parse::<ColExpr>().unwrap(), + ColExpr::new(Col::Size, ColOperator::Lower, "32G"), + ); +} + +#[derive(Debug, PartialEq)] +pub enum EvalExprError { + NotANumber(String), + NotAnId(String), + NotADeviceId(String), +} +impl EvalExprError { +} +impl fmt::Display for EvalExprError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotANumber(s) => { + write!(f, "{:?} can't be evaluated as a number", &s) + } + Self::NotAnId(s) => { + write!(f, "{:?} can't be evaluated as an id", &s) + } + Self::NotADeviceId(s) => { + write!(f, "{:?} can't be evaluated as a device id", &s) + } + } + } +} +impl std::error::Error for EvalExprError {} + +fn parse_bool(s: &str) -> bool { + let s = s.to_lowercase(); + s == "x" || s == "true" || s == "yes" || s == "1" +} + +/// Parse numbers like "1234", "32G", "4kB", "54Gib", "1.2M" +fn parse_integer(input: &str) -> Result<u64, EvalExprError> { + let s = input.to_lowercase(); + let s = s.trim_end_matches('b'); + let (s, binary) = match s.strip_suffix('i') { + Some(s) => (s, true), + None => (s, false), + }; + let cut = s.find(|c: char| !(c.is_digit(10) || c=='.')); + let (digits, factor): (&str, u64) = match cut { + Some(idx) => ( + &s[..idx], + match (&s[idx..], binary) { + ("k", false) => 1000, + ("k", true) => 1024, + ("m", false) => 1000*1000, + ("m", true) => 1024*1024, + ("g", false) => 1000*1000*1000, + ("g", true) => 1024*1024*1024, + ("t", false) => 1000*1000*1000*1000, + ("t", true) => 1024*1024*1024*1024, + _ => { + // it's not a number + return Err(EvalExprError::NotANumber(input.to_string())); + } + } + ), + None => (s, 1), + }; + match digits.parse::<f64>() { + Ok(n) => Ok((n * factor as f64).ceil() as u64), + _ => Err(EvalExprError::NotANumber(input.to_string())), + } +} + +#[test] +fn test_parse_integer(){ + assert_eq!(parse_integer("33"), Ok(33)); + assert_eq!(parse_integer("55G"), Ok(55_000_000_000)); + assert_eq!(parse_integer("1.23kiB"), Ok(1260)); +} + +/// parse numbers like "0.25", "50%" +fn parse_float(input: &str) -> Result<f64, EvalExprError> { + let s = input.to_lowercase(); + let (s, percent) = match s.strip_suffix('%') { + Some(s) => (s, true), + None => (s.as_str(), false), + }; + let mut n = s.parse::<f64>() + .map_err(|_| EvalExprError::NotANumber(input.to_string()))?; + if percent { + n /= 100.0; + } + Ok(n) +} + +#[test] +fn test_parse_float(){ + assert_eq!(parse_float("50%").unwrap().to_string(), "0.5".to_string()); +} + diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..7db7ce2 --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,80 @@ +use { + crate::{ + col_expr::*, + }, + bet::*, + lfs_core::*, + std::{ + str::FromStr, + }, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BoolOperator { + And, + Or, + Not, +} + +#[derive(Debug, Default)] +pub struct Filter { + expr: BeTree<BoolOperator, ColExpr>, +} + +impl Filter { + pub fn eval(&self, mount: &Mount) -> Result<bool, EvalExprError> { + self.expr.eval_faillible( + // leaf evaluation + |col_expr| col_expr.eval(mount), + // bool operation + |op, a, b| match (op, b) { + (BoolOperator::And, Some(b)) => Ok(a & b), + (BoolOperator::Or, Some(b)) => Ok(a | b), + (BoolOperator::Not, None) => Ok(!a), + _ => { unreachable!() } + }, + // when to short-circuit + |op, a| match (op, a) { + (BoolOperator::And, false) => true, + (BoolOperator::Or, true) => true, + _ => false, + }, + ).map(|b| b.unwrap_or(true)) + } + pub fn filter<'m>(&self, mounts: &'m[Mount]) -> Result<Vec<&'m Mount>, EvalExprError> { + let mut filtered = Vec::new(); + for mount in mounts { + if self.eval(mount)? { + filtered.push(mount); + } + } + Ok(filtered) + } +} + +impl FromStr for Filter { + type Err = ParseExprError; + fn from_str(input: &str) -> Result<Self, ParseExprError> { + + // we start by reading the global structure + let mut expr: BeTree<BoolOperator, String> = BeTree::new(); + for c in input.chars() { + match c { + '&' => expr.push_operator(BoolOperator::And), + '|' => expr.push_operator(BoolOperator::Or), + '!' => expr.push_operator(BoolOperator::Not), + ' ' => {}, + '(' => expr.open_par(), + ')' => expr.close_par(), + _ => expr.mutate_or_create_atom(String::new).push(c), + } + } + + // then we parse each leaf + let expr = expr.try_map_atoms(|raw| raw.parse())?; + + Ok(Self { expr }) + } +} + + diff --git a/src/main.rs b/src/main.rs index 7e3b7a3..ede0a5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod args; mod col; +mod col_expr; mod cols; +mod filter; mod json; mod list_cols; mod normal; @@ -58,11 +60,18 @@ fn main() { ); return; } + args.sort.sort(&mut mounts); + let mounts = match args.filter.filter(&mounts) { + Ok(mounts) => mounts, + Err(e) => { + eprintln!("Error in filter evaluation: {}", e); + return; + } + }; if mounts.is_empty() { println!("no mount to display - try\n lfs -a"); return; } - args.sort.sort(&mut mounts); table::print(&mounts, args.color(), &args); } diff --git a/src/table.rs b/src/table.rs index 07e1c9d..d048529 100644 --- a/src/table.rs +++ b/src/table.rs @@ -20,7 +20,7 @@ static SIZE_COLOR: u8 = 172; static BAR_WIDTH: usize = 5; static INODES_BAR_WIDTH: usize = 5; -pub fn print(mounts: &[Mount], color: bool, args: &Args) { +pub fn print(mounts: &[&Mount], color: bool, args: &Args) { if args.cols.is_empty() { return; } |