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() {