diff --git a/CHANGELOG.md b/CHANGELOG.md index f10e74aa..b219fac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ That said, these are more guidelines rather than hard rules, though the project - [#2003](https://github.com/ClementTsang/bottom/pull/2003): Configurable default sort column for temperature and disk table widgets. - [#1979](https://github.com/ClementTsang/bottom/pull/1979): Add global `bg_color` option to set widget background colour. - [#2039](https://github.com/ClementTsang/bottom/pull/2039): Support drawing a line separator between the column headers and data. +- [#1948](https://github.com/ClementTsang/bottom/pull/1948): Support `!=` operator and `!` negation prefixes in query searches. ### Changes diff --git a/docs/content/usage/widgets/process.md b/docs/content/usage/widgets/process.md index 27524d3b..3766f5ae 100644 --- a/docs/content/usage/widgets/process.md +++ b/docs/content/usage/widgets/process.md @@ -177,6 +177,7 @@ Note all keywords are case-insensitive. To search for a process/command that col | Keywords | Description | | -------- | -------------------------------------------------------------- | | `=` | Checks if the values are equal | +| `!=` | Checks if the values are not equal | | `>` | Checks if the left value is strictly greater than the right | | `<` | Checks if the left value is strictly less than the right | | `>=` | Checks if the left value is greater than or equal to the right | @@ -190,6 +191,9 @@ Note all operators are case-insensitive, and the `and` operator takes precedence | ------------------------------------ | ------------------------------------------------------------------------------ | --------------------------------------------------- | | `and`
`&&`
`` | ` and `
` && `
` ` | Requires both conditions to be true to match | | `or`
|| | ` or `
` || ` | Requires at least one condition to be true to match | +| `!` | `!`
`!( or )` | Inverts the following condition or group | + +`!` is reserved as an operator, so it cannot appear bare as a value. To match a literal `!` in a name or string field, quote it — e.g. `"foo!"` or `user = "!"`. A bare `!` with nothing parseable after it (such as `user = !` or `!` on its own) is rejected. #### Units diff --git a/src/constants.rs b/src/constants.rs index 48788377..c78aed1b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -84,7 +84,7 @@ const PROCESS_HELP_TEXT: [&str; 20] = [ "z Toggle the display of kernel threads", ]; -const SEARCH_HELP_TEXT: [&str; 51] = [ +const SEARCH_HELP_TEXT: [&str; 53] = [ "4 - Process search widget", "Esc Close the search widget (retains the filter)", "Ctrl-a Skip to the start of the search query", @@ -118,6 +118,7 @@ const SEARCH_HELP_TEXT: [&str; 51] = [ "", "Comparison operators:", "= ex: cpu = 1", + "!= ex: cpu != 1", "> ex: cpu > 1", "< ex: cpu < 1", ">= ex: cpu >= 1", @@ -126,6 +127,7 @@ const SEARCH_HELP_TEXT: [&str; 51] = [ "Logical operators:", "and, &&, ex: btm and cpu > 1 and mem > 1", "or, || ex: btm or firefox", + "! ex: !firefox, !(cpu > 5 or btm)", "", "Supported units:", "B ex: read > 1 b", diff --git a/src/utils/input.rs b/src/utils/input.rs index ffad912b..45cf8913 100644 --- a/src/utils/input.rs +++ b/src/utils/input.rs @@ -577,7 +577,7 @@ mod tests { state.move_left(); assert_eq!(state.cursor_index(), 0); - // At the start — no further movement + // At the start - no further movement state.move_left(); assert_eq!(state.cursor_index(), 0); @@ -588,7 +588,7 @@ mod tests { state.move_right(); assert_eq!(state.cursor_index(), 3); - // At the end — no further movement + // At the end - no further movement state.move_right(); assert_eq!(state.cursor_index(), 3); } @@ -717,7 +717,7 @@ mod tests { /// producing the wrong result or panicking. #[test] fn delete_previous_word_unicode() { - // "你好 world" — '你'=3 bytes, '好'=3 bytes, ' '=1, "world"=5, so 12 bytes + // "你好 world" - '你'=3 bytes, '好'=3 bytes, ' '=1, "world"=5, so 12 bytes // total let mut state = InputFieldState::default(); state.insert_string("你好 world".to_string()); @@ -827,13 +827,13 @@ mod tests { assert_eq!(state.grapheme_cursor.cur_cursor(), 20); assert_eq!(state.display_start_index, 11); - // Clamped at the end — no further movement. + // Clamped at the end - no further movement. state.move_right(); state.get_start_position(4, false); assert_eq!(state.grapheme_cursor.cur_cursor(), 20); assert_eq!(state.display_start_index, 11); - // Moving left — back over the flag emoji. + // Moving left - back over the flag emoji. state.move_left(); state.get_start_position(4, false); assert_eq!(state.grapheme_cursor.cur_cursor(), 12); diff --git a/src/widgets/process_table/query.rs b/src/widgets/process_table/query.rs index b42e7d69..9a239772 100644 --- a/src/widgets/process_table/query.rs +++ b/src/widgets/process_table/query.rs @@ -22,8 +22,8 @@ use regex::Regex; use crate::{collection::processes::ProcessHarvest, multi_eq_ignore_ascii_case}; -const DELIMITER_LIST: [char; 6] = ['=', '>', '<', '(', ')', '\"']; -const COMPARISON_LIST: [&str; 3] = [">", "=", "<"]; +const DELIMITER_LIST: [char; 7] = ['=', '>', '<', '!', '(', ')', '\"']; +const COMPARISON_LIST: [&str; 4] = [">", "=", "<", "!="]; /// A node type that can take a query and read it, advancing the current read /// state and returning an instance of the node. @@ -136,6 +136,16 @@ pub(crate) fn parse_query(search_query: &str, options: &QueryOptions) -> QueryRe } }); + // Merge adjacent "!" and "=" tokens into a single "!=" token. + let mut i = 0; + while i + 1 < split_query.len() { + if split_query[i] == "!" && split_query[i + 1] == "=" { + split_query[i] = "!=".to_owned(); + split_query.remove(i + 1); + } + i += 1; + } + process_string_to_filter(&mut split_query, options) } @@ -187,6 +197,8 @@ impl std::str::FromStr for PrefixType { // TODO: Didn't add mem_bytes, total_read, and total_write // for now as it causes help to be clogged. + // TODO: Add a `name` keyword alias so something like `name = blah` is valid. + let mut result = Name; if multi_eq_ignore_ascii_case!(s, "cpu" | "cpu%") { result = CpuPercentage; @@ -235,6 +247,7 @@ impl std::str::FromStr for PrefixType { #[derive(Debug)] enum QueryComparison { Equal, + NotEqual, Less, Greater, LessOrEqual, @@ -255,6 +268,7 @@ impl NumericalQuery { match self.condition { QueryComparison::Equal => (lhs - rhs).abs() < f64::EPSILON, + QueryComparison::NotEqual => (lhs - rhs).abs() >= f64::EPSILON, QueryComparison::Less => lhs < rhs, QueryComparison::Greater => lhs > rhs, QueryComparison::LessOrEqual => lhs <= rhs, @@ -276,6 +290,7 @@ impl TimeQuery { match self.condition { QueryComparison::Equal => lhs == rhs, + QueryComparison::NotEqual => lhs != rhs, QueryComparison::Less => lhs < rhs, QueryComparison::Greater => lhs > rhs, QueryComparison::LessOrEqual => lhs <= rhs, @@ -866,4 +881,253 @@ mod tests { // assert!(mem.check(&process_a, false)); // } + + /// Basic numerical non-equality operator test. + #[test] + fn numerical_not_equal_query() { + let query = parse_query_no_options("cpu != 50").unwrap(); + + let mut equal = simple_process("a"); + equal.cpu_usage_percent = 50.0; + + let mut other = simple_process("a"); + other.cpu_usage_percent = 40.0; + + assert!(!query.check(&equal, false)); + assert!(query.check(&other, false)); + } + + /// `!=` should tokenize the same whether written as `foo!=bar` or + /// `foo != bar`. + #[test] + fn not_equal_tokenizes_without_spaces() { + let spaced = parse_query_no_options("cpu != 50").unwrap(); + let joined = parse_query_no_options("cpu!=50").unwrap(); + + let mut proc = simple_process("a"); + proc.cpu_usage_percent = 40.0; + + assert!(spaced.check(&proc, false)); + assert!(joined.check(&proc, false)); + } + + /// Test the non-equality operator with time. + #[test] + fn time_not_equal_query() { + let query = parse_query_no_options("time != 1h").unwrap(); + + let mut equal = simple_process("a"); + equal.time = Duration::from_secs(3600); + + let mut other = simple_process("a"); + other.time = Duration::from_secs(60); + + assert!(!query.check(&equal, false)); + assert!(query.check(&other, false)); + } + + /// Test state queries with the non-equality operator. This also tests that string comparisons. + #[test] + fn state_not_equal_query() { + let query = parse_query_no_options("state != sleeping").unwrap(); + + let mut sleeping = simple_process("a"); + sleeping.process_state.0 = "sleeping"; + + let mut running = simple_process("a"); + running.process_state.0 = "running"; + + assert!(!query.check(&sleeping, false)); + assert!(query.check(&running, false)); + } + + /// Test byte unit searches work with the non-equality operator. + #[test] + fn mem_bytes_not_equal_with_unit() { + let query = parse_query_no_options("memb != 1 GiB").unwrap(); + + let mut equal = simple_process("a"); + equal.mem_usage = 1024 * 1024 * 1024; + + let mut other = simple_process("a"); + other.mem_usage = 0; + + assert!(!query.check(&equal, false)); + assert!(query.check(&other, false)); + } + + /// `!(...)` negates a whole group. + #[test] + fn negate_group_query() { + let query = parse_query_no_options("!(cpu > 5 or a)").unwrap(); + + let mut high_cpu = simple_process("b"); + high_cpu.cpu_usage_percent = 10.0; + + let name_match = simple_process("a"); + + let mut low_cpu_no_match = simple_process("b"); + low_cpu_no_match.cpu_usage_percent = 1.0; + + assert!(!query.check(&high_cpu, false)); + assert!(!query.check(&name_match, false)); + assert!(query.check(&low_cpu_no_match, false)); + } + + /// `!name` negates a bare-name match. + #[test] + fn negate_bare_name_query() { + let query = parse_query_no_options("!firefox").unwrap(); + + let firefox = simple_process("firefox"); + let other = simple_process("btm"); + + assert!(!query.check(&firefox, false)); + assert!(query.check(&other, false)); + } + + /// `!"..."` negates a quoted-name match. + #[test] + fn negate_quoted_name_query() { + let query = parse_query_no_options("!\"a or b\"").unwrap(); + + let exact = simple_process("a or b"); + let other = simple_process("c"); + + assert!(!query.check(&exact, false)); + assert!(query.check(&other, false)); + } + + /// `!!x` is double negation, which should behave like `x`. + #[test] + fn double_negate_query_a() { + let query = parse_query_no_options("!!firefox").unwrap(); + + let firefox = simple_process("firefox"); + let other = simple_process("btm"); + + assert!(query.check(&firefox, false)); + assert!(!query.check(&other, false)); + } + + /// `!!(cpu > 5)` is double negation, which should behave like `cpu > 5`. + #[test] + fn double_negate_query_b() { + let query = parse_query_no_options("!!(cpu > 5) ").unwrap(); + + let mut big = simple_process("big"); + big.cpu_usage_percent = 10.0; + let mut small = simple_process("small"); + small.cpu_usage_percent = 1.0; + + assert!(query.check(&big, false)); + assert!(!query.check(&small, false)); + } + + /// `!` composes with `and`/`or`. + #[test] + fn negate_with_boolean_operators() { + let query = parse_query_no_options("!firefox and cpu > 5").unwrap(); + + let mut firefox = simple_process("firefox"); + firefox.cpu_usage_percent = 10.0; + + let mut other_high = simple_process("btm"); + other_high.cpu_usage_percent = 10.0; + + let mut other_low = simple_process("btm"); + other_low.cpu_usage_percent = 1.0; + + assert!(!query.check(&firefox, false)); + assert!(query.check(&other_high, false)); + assert!(!query.check(&other_low, false)); + } + + /// A bare `!` with nothing parseable after it must be rejected so that it + /// doesn't silently become `name = "!"`. The literal form `"!"` still + /// works. + #[test] + fn lone_bang_is_rejected() { + parse_query_no_options("!").unwrap_err(); + parse_query_no_options("! ").unwrap_err(); + parse_query_no_options("!)").unwrap_err(); + parse_query_no_options("(!)").unwrap_err(); + } + + /// A quoted `"!"` as the RHS of a prefixed string query should match the + /// literal `!`, not be treated as an operator. + #[test] + fn user_equals_quoted_bang() { + let query = parse_query_no_options("user = \"!\"").unwrap(); + + let mut with_bang = simple_process("a"); + with_bang.user = Some("!".into()); + + let mut other = simple_process("a"); + other.user = Some("root".into()); + + assert!(query.check(&with_bang, false)); + assert!(!query.check(&other, false)); + } + + /// A quoted `"!"` as the RHS of a prefixed string query with "!=" should not match the + /// literal `!`, nor should it be treated as an operator. + #[test] + fn user_negated_equals_quoted_bang() { + let query = parse_query_no_options("user != \"!\"").unwrap(); + + let mut with_bang = simple_process("a"); + with_bang.user = Some("!".into()); + + let mut other = simple_process("a"); + other.user = Some("root".into()); + + assert!(!query.check(&with_bang, false)); + assert!(query.check(&other, false)); + } + + /// A quoted `"!"` should remain a valid literal-name match. + #[test] + fn quoted_bang_matches_literal() { + let query = parse_query_no_options("\"!\"").unwrap(); + + let with_bang = simple_process("foo!"); + let without = simple_process("foo"); + + assert!(query.check(&with_bang, false)); + assert!(!query.check(&without, false)); + } + + /// Trailing operators with no RHS must error. + #[test] + fn not_equal_missing_value_is_rejected() { + parse_query_no_options("user !=").unwrap_err(); + parse_query_no_options("state !=").unwrap_err(); + parse_query_no_options("time !=").unwrap_err(); + } + + /// Some miscellaneous invalid string searches involving negation (`!`) parsing. + #[test] + fn misc_invalid_bang_search() { + parse_query_no_options("user = !").unwrap_err(); + parse_query_no_options("user != !").unwrap_err(); + parse_query_no_options("pid = !").unwrap_err(); + parse_query_no_options("state !").unwrap_err(); + parse_query_no_options("!cpu > 5").unwrap_err(); + parse_query_no_options("! cpu > 5").unwrap_err(); + } + + /// `=!` is not a valid operator. + #[test] + fn reversed_bang_equal_is_rejected() { + parse_query_no_options("cpu =!").unwrap_err(); + parse_query_no_options("cpu = !").unwrap_err(); + parse_query_no_options("cpu = ! 5").unwrap_err(); + } + + /// `!=` with a non-numeric RHS should fail. + #[test] + fn not_equal_non_numeric_rhs_is_rejected() { + parse_query_no_options("cpu != abc").unwrap_err(); + } } diff --git a/src/widgets/process_table/query/prefix.rs b/src/widgets/process_table/query/prefix.rs index de5b02f9..10ce87f1 100644 --- a/src/widgets/process_table/query/prefix.rs +++ b/src/widgets/process_table/query/prefix.rs @@ -58,8 +58,14 @@ fn process_prefix_units(query: &mut VecDeque, value: &mut f64) { /// now, it's hardcoded for processes. #[derive(Debug)] pub(super) enum Prefix { + /// True if the inner OR is true (allowing a recursive tree). Or(Box), + /// A leaf node. Attribute(ProcessAttribute), + /// Invert the match result of the inner prefix. + /// + /// TODO: Also support reading with "not". + Negate(Box), } impl Prefix { @@ -67,6 +73,7 @@ impl Prefix { match self { Prefix::Or(or) => or.check(process, is_using_command), Prefix::Attribute(attribute) => attribute.check(process, is_using_command), + Prefix::Negate(inner) => !inner.check(process, is_using_command), } } @@ -162,6 +169,31 @@ impl QueryProcessor for Prefix { }; } else if curr == ")" { return Err(QueryError::new("Missing opening parentheses")); + } else if curr == "!" { + // Negation prefix: `!` inverts the match of the following + // expression. Handles: + // - Groups (`!(a or b)`) + // - Quoted names (`!"foo"`) + // - Bare names (`!foo`) + // - Stacked `!` (`!!foo`). + match query.front().map(|s| s.as_str()) { + None | Some("=") | Some(">") | Some("<") | Some(")") => { + return Err(QueryError::new( + "`!` must be followed by an expression; use `\"!\"` to match a literal `!`", + )); + } + Some(next) if next != "(" && next != "\"" && next != "!" => { + let next: Result = next.parse(); + if !matches!(next, Ok(PrefixType::Name)) { + return Err(QueryError::new( + "`!` cannot be applied to a prefix keyword; use `!=`, `<=`, `>=` or group with `!(...)` instead", + )); + } + } + _ => {} + } + let inner = Prefix::process(query, options)?; + return Ok(Prefix::Negate(Box::new(inner))); } else if curr == "\"" { // Similar to parentheses, trap and check for missing closing quotes. Note, // however, that we will DIRECTLY call another process_prefix @@ -198,10 +230,17 @@ impl QueryProcessor for Prefix { )?)); } PrefixType::Pid | PrefixType::State | PrefixType::User => { - // We have to check if someone put an "="... - if content == "=" { + // We have to check if someone put an (in)equality check... + if content == "=" || content == "!=" { + let negate = content.starts_with('!'); + // Check next string if possible if let Some(string_value) = query.pop_front() { + if string_value == "!" { + return Err(QueryError::new( + "`!` is reserved; use `\"!\"` to match the literal character", + )); + } // TODO: [Query] Need to consider the following cases: // - (test) // - (test @@ -232,12 +271,22 @@ impl QueryProcessor for Prefix { string_value }; - return Ok(Prefix::Attribute(new_string_attribute( + let inner_attribute = Prefix::Attribute(new_string_attribute( prefix_type, &final_value, options, - )?)); + )?); + + return Ok(if negate { + Prefix::Negate(Box::new(inner_attribute)) + } else { + inner_attribute + }); } + } else if content == "!" { + return Err(QueryError::new( + "`!` is reserved; use `\"!\"` to match the literal character", + )); } else { return Ok(Prefix::Attribute(new_string_attribute( prefix_type, @@ -253,6 +302,9 @@ impl QueryProcessor for Prefix { if content == "=" { condition = Some(QueryComparison::Equal); duration_string = query.pop_front(); + } else if content == "!=" { + condition = Some(QueryComparison::NotEqual); + duration_string = query.pop_front(); } else if content == ">" || content == "<" { if let Some(queue_next) = query.pop_front() { if queue_next == "=" { @@ -291,8 +343,8 @@ impl QueryProcessor for Prefix { } } _ => { - // Assume it's some numerical value. - // Now we gotta parse the content... yay. + // Assume it's some numerical value. Now we gotta parse the content... yay. + // Note that for numerical parsing, we handle unit parsing later, not here. let mut condition: Option = None; let mut value: Option = None; @@ -306,6 +358,13 @@ impl QueryProcessor for Prefix { } else { return Err(QueryError::missing_value()); } + } else if content == "!=" { + condition = Some(QueryComparison::NotEqual); + if let Some(queue_next) = query.pop_front() { + value = queue_next.parse::().ok(); + } else { + return Err(QueryError::missing_value()); + } } else if content == ">" || content == "<" { // We also have to check if the next string is an "="... if let Some(queue_next) = query.pop_front() {