From 8aed54445c29f2573e3a24ea7cbfba8ce65845b6 Mon Sep 17 00:00:00 2001 From: Danny Stoll Date: Tue, 7 Jul 2026 17:20:23 -0500 Subject: [PATCH] Add column-scoped search predicates (Fixes #776) Introduce column predicates as a general way to filter by a single field, addressing the "filter processes of a user" request without a special-purpose flag: `procs user==root` shows only root's processes, `procs command~=dockerd` matches a substring of the command. A search keyword now parses into either a plain keyword (matched against every searchable column, as before) or a `` predicate: == exact, full-string match ~= substring match The column name resolves case-insensitively against the existing column kinds, and a predicate matches its target column regardless of that column's numeric_search/nonnumeric_search flags (so `pid==1` works). Predicates are ordinary terms, so they compose with plain keywords and the existing `--and`/`--or`/`--nand`/`--nor` logic: procs --and user==root command~=dockerd Internally, `View::filter` is refactored from combining numeric/nonnumeric keyword groups to a per-term fold. Each term compiles to the set of columns it searches plus the operator, then the active logic folds per-term results. This is equivalent to the previous behavior for plain keywords and generalizes cleanly to predicates. Unknown column names and columns absent from the active layout are reported as errors before output. Predicate parsing is disabled in --regex mode. --- CHANGELOG.md | 2 + README.md | 27 ++++ man/procs.1.adoc | 4 + src/main.rs | 40 ++++++ src/search.rs | 126 +++++++++++++++++++ src/util.rs | 1 - src/view.rs | 319 +++++++++++++++++++++++++++++------------------ 7 files changed, 394 insertions(+), 125 deletions(-) create mode 100644 src/search.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index db4ba74bc..ef55d8f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased](https://github.com/dalance/procs/compare/v0.14.12...Unreleased) - ReleaseDate +* [Added] Add column predicate search, e.g. `user==root` and `command~=dockerd` (Fixes #776) + ## [v0.14.12](https://github.com/dalance/procs/compare/v0.14.11...v0.14.12) - 2026-06-25 * [Added] Add fancy regex fallback for advanced filters [#913](https://github.com/dalance/procs/pull/913) diff --git a/README.md b/README.md index a3979fe5f..7b7e0fae6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - Colored and human-readable output - Automatic theme detection based on terminal background - Multi-column keyword search +- Column predicate search (e.g. `user==root`, `command~=dockerd`) - Some additional information which are not supported by `ps` - TCP/UDP port - Read/Write throughput @@ -206,6 +207,32 @@ If you want to add columns matching to numeric keyword, `numeric_search` option Note that procfs permissions only allow identifying listening ports for processes owned by the current user, so not all ports will show up unless run as root. +### Search by column predicate + +A keyword of the form `` matches against a single named column, regardless of that column's `numeric_search` / `nonnumeric_search` settings. +For example, to show only one user's processes: + +```console +procs user==root +procs command~=dockerd +``` + +There are two operators: + +- `==` : the column content equals the value exactly. +- `~=` : the column content contains the value as a substring. + +The column name is resolved case insensitively against the [column kinds](#configuration), and a substring is enough (e.g. `user`, `command`, `pid`). +Only the value after the operator can contain further `=` characters. +Predicates are ordinary search keywords, so they combine with plain keywords and with the [logical operators](#logical-operation-of-search-keywords): + +```console +procs --and user==root command~=dockerd +``` + +The referenced column must be present in the current layout; otherwise add it through the [configuration file](#configuration) or `--insert`. +Predicate parsing is disabled in `--regex` mode, where the pattern is taken verbatim. + ### Logical operation of search keywords If there are some keywords, logical operation between the keywords can be specified by commandline option. diff --git a/man/procs.1.adoc b/man/procs.1.adoc index 004d97f22..738d16e85 100644 --- a/man/procs.1.adoc +++ b/man/procs.1.adoc @@ -38,6 +38,10 @@ procs - a replacement for `ps` written in Rust *Search by numeric keyword*:: procs --or 6000 60000 60001 16723 +*Search by column predicate*:: procs user==root + +*Search by partial column predicate*:: procs command~=dockerd + *Show Docker container name*:: procs growi == PAGER diff --git a/src/main.rs b/src/main.rs index fddad48b0..092b90ac2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod columns; mod config; mod opt; mod process; +mod search; mod search_regex; mod style; mod term_info; @@ -310,6 +311,45 @@ mod tests { assert!(ret.is_ok()); } + #[test] + fn test_run_predicate() { + let mut config: Config = toml::from_str(CONFIG_DEFAULT).unwrap(); + config.pager.mode = ConfigPagerMode::Disable; + config.display.theme = ConfigTheme::Dark; + + let args = ["procs", "user==root"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_ok()); + + let args = ["procs", "command~=proc"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_ok()); + + let args = ["procs", "--and", "user==root", "pid~=1"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_ok()); + + let args = ["procs", "--or", "user==root", "1"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_ok()); + + // Unknown column is rejected. + let args = ["procs", "bogus==x"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_err()); + + // A column that exists but is not in the active layout is rejected. + let args = ["procs", "env==FOO"]; + let mut opt = Opt::parse_from(args.iter()); + let ret = run_default(&mut opt, &config); + assert!(ret.is_err()); + } + #[test] fn test_run_gen_config() { let ret = run_gen_config(); diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 000000000..61ec645ee --- /dev/null +++ b/src/search.rs @@ -0,0 +1,126 @@ +use crate::columns::ConfigColumnKind; +use crate::util::find_column_kind; +use anyhow::{Error, bail}; + +/// Matching operator of a column predicate. +pub enum MatchOp { + /// `==`: the column content must equal the value exactly. + Exact, + /// `~=`: the column content must contain the value as a substring. + Partial, +} + +impl MatchOp { + const EXACT: &'static str = "=="; + const PARTIAL: &'static str = "~="; + + fn token(&self) -> &'static str { + match self { + MatchOp::Exact => MatchOp::EXACT, + MatchOp::Partial => MatchOp::PARTIAL, + } + } +} + +/// A predicate constraining a single column, e.g. `user==root` or +/// `command~=dockerd`. +pub struct ColumnPredicate<'a> { + pub kind: ConfigColumnKind, + pub op: MatchOp, + pub value: &'a str, +} + +/// A single parsed search term borrowing from the raw keyword. +pub enum SearchTerm<'a> { + /// A bare keyword matched against every searchable column. + Plain(&'a str), + /// A predicate matched against one named column. + Column(ColumnPredicate<'a>), +} + +impl<'a> SearchTerm<'a> { + /// Parse a raw keyword into a [`SearchTerm`]. + /// + /// A keyword containing a [`MatchOp`] operator is interpreted as a column + /// predicate; the substring before the leftmost operator names the column + /// and the remainder is the value. Any other keyword is a plain keyword. + /// + /// Returns an error if a predicate names a column that does not exist. + pub fn parse(raw: &'a str) -> Result { + let exact = raw.find(MatchOp::EXACT); + let partial = raw.find(MatchOp::PARTIAL); + + let (op, idx) = match (exact, partial) { + (Some(i), Some(j)) if i <= j => (MatchOp::Exact, i), + (Some(_), Some(j)) => (MatchOp::Partial, j), + (Some(i), None) => (MatchOp::Exact, i), + (None, Some(j)) => (MatchOp::Partial, j), + (None, None) => return Ok(SearchTerm::Plain(raw)), + }; + + let column = &raw[..idx]; + let value = &raw[idx + op.token().len()..]; + + if column.is_empty() { + bail!("empty column name in predicate \"{raw}\""); + } + + let kind = find_column_kind(column) + .ok_or_else(|| anyhow::anyhow!("unknown column \"{column}\" in predicate \"{raw}\""))?; + + Ok(SearchTerm::Column(ColumnPredicate { kind, op, value })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plain_keyword() { + assert!(matches!( + SearchTerm::parse("firefox").unwrap(), + SearchTerm::Plain("firefox") + )); + } + + #[test] + fn parses_exact_predicate() { + let term = SearchTerm::parse("user==root").unwrap(); + let SearchTerm::Column(pred) = term else { + panic!("expected column predicate"); + }; + assert!(matches!(pred.op, MatchOp::Exact)); + assert_eq!(pred.value, "root"); + } + + #[test] + fn parses_partial_predicate() { + let term = SearchTerm::parse("command~=docker").unwrap(); + let SearchTerm::Column(pred) = term else { + panic!("expected column predicate"); + }; + assert!(matches!(pred.op, MatchOp::Partial)); + assert_eq!(pred.value, "docker"); + } + + #[test] + fn leftmost_operator_wins_and_value_may_contain_equals() { + let term = SearchTerm::parse("command~=a==b").unwrap(); + let SearchTerm::Column(pred) = term else { + panic!("expected column predicate"); + }; + assert!(matches!(pred.op, MatchOp::Partial)); + assert_eq!(pred.value, "a==b"); + } + + #[test] + fn unknown_column_is_error() { + assert!(SearchTerm::parse("nosuchcolumn==x").is_err()); + } + + #[test] + fn empty_column_is_error() { + assert!(SearchTerm::parse("==x").is_err()); + } +} diff --git a/src/util.rs b/src/util.rs index 3688d92c7..365f67713 100644 --- a/src/util.rs +++ b/src/util.rs @@ -248,7 +248,6 @@ pub fn find_column_kind(pat: &str) -> Option { return Some(k.clone()); } } - eprintln!("Can't find column kind: {pat}"); None } diff --git a/src/view.rs b/src/view.rs index a8ed8b3a9..cad7f1b29 100644 --- a/src/view.rs +++ b/src/view.rs @@ -7,6 +7,7 @@ use crate::process::collect_proc; use crate::search_regex::SearchRegex; use crate::style::{apply_color, apply_style, color_to_column_style}; use crate::term_info::TermInfo; +use crate::search::{ColumnPredicate, MatchOp, SearchTerm}; use crate::util::{ KeywordClass, ansi_trim_end, classify, find_column_kind, find_exact, find_partial, has_regex_syntax, truncate, @@ -22,6 +23,132 @@ pub struct SortInfo { pub order: ConfigSortOrder, } +/// A search term reduced to the columns it matches against and how. +struct CompiledTerm<'a> { + cols: Vec<&'a dyn Column>, + exact: bool, + needle: &'a str, +} + +impl CompiledTerm<'_> { + fn matches(&self, pid: i32, case: &ConfigSearchCase) -> bool { + let needle = [self.needle]; + if self.exact { + find_exact(&self.cols, pid, &needle, &ConfigSearchLogic::And, case) + } else { + find_partial(&self.cols, pid, &needle, &ConfigSearchLogic::And, case) + } + } +} + +/// The compiled matching strategy for a set of search keywords. +enum Search<'a> { + /// No keywords: every process matches. + All, + /// A single regex applied over the searchable columns. + Regex { + cols: Vec<&'a dyn Column>, + regex: SearchRegex, + }, + /// Per-keyword terms folded together by the active logic. + Terms { + terms: Vec>, + case: ConfigSearchCase, + logic: ConfigSearchLogic, + }, +} + +impl<'a> Search<'a> { + /// Compile the keywords in `opt` against the columns of `view`. + fn new(view: &'a View, opt: &'a Opt, config: &Config) -> Result { + if opt.keyword.is_empty() { + return Ok(Search::All); + } + + let regex_mode = if opt.regex { + true + } else if opt.smart { + opt.keyword.len() == 1 && has_regex_syntax(&opt.keyword[0]) + } else { + false + }; + + if regex_mode { + let pattern = &opt.keyword[0]; + let ignore_case = match config.search.case { + ConfigSearchCase::Smart => pattern == &pattern.to_ascii_lowercase(), + ConfigSearchCase::Insensitive => true, + ConfigSearchCase::Sensitive => false, + }; + return Ok(Search::Regex { + cols: view.searchable_columns().collect(), + regex: SearchRegex::new(pattern, ignore_case)?, + }); + } + + let mut terms = Vec::with_capacity(opt.keyword.len()); + for k in &opt.keyword { + terms.push(view.compile_term(k, config)?); + } + Ok(Search::Terms { + terms, + case: config.search.case.clone(), + logic: search_logic(opt, config), + }) + } + + /// Whether the process `pid` satisfies the search. + fn matches(&self, pid: i32) -> Result { + match self { + Search::All => Ok(true), + Search::Regex { cols, regex } => View::search_regex(pid, cols, regex), + Search::Terms { terms, case, logic } => { + Ok(Self::fold(pid, terms.iter(), case, logic)) + } + } + } + + /// Fold the per-term match results with the active search logic. + fn fold<'t>( + pid: i32, + mut terms: impl Iterator>, + case: &ConfigSearchCase, + logic: &ConfigSearchLogic, + ) -> bool { + match logic { + ConfigSearchLogic::And => terms.all(|t| t.matches(pid, case)), + ConfigSearchLogic::Or => terms.any(|t| t.matches(pid, case)), + ConfigSearchLogic::Nand => !terms.all(|t| t.matches(pid, case)), + ConfigSearchLogic::Nor => !terms.any(|t| t.matches(pid, case)), + } + } +} + +/// Select the active search logic from the command line, falling back to the +/// configured default. +fn search_logic(opt: &Opt, config: &Config) -> ConfigSearchLogic { + if opt.and { + ConfigSearchLogic::And + } else if opt.or { + ConfigSearchLogic::Or + } else if opt.nand { + ConfigSearchLogic::Nand + } else if opt.nor { + ConfigSearchLogic::Nor + } else { + config.search.logic.clone() + } +} + +/// Resolve a `--insert` column argument, warning on stderr when it is unknown. +fn resolve_insert_kind(insert: &str) -> Option { + let kind = find_column_kind(insert); + if kind.is_none() { + eprintln!("Can't find column kind: {insert}"); + } + kind +} + pub struct View { pub columns: Vec, pub term_info: TermInfo, @@ -86,7 +213,7 @@ impl View { let kinds = match &c.kind { ConfigColumnKind::Slot => { let kinds = if let Some(insert) = opt.insert.get(slot_idx) { - find_column_kind(insert).into_iter().collect() + resolve_insert_kind(insert).into_iter().collect() } else { vec![] }; @@ -96,7 +223,7 @@ impl View { ConfigColumnKind::MultiSlot => { let mut kinds = vec![]; while let Some(insert) = opt.insert.get(slot_idx) { - if let Some(kind) = find_column_kind(insert) { + if let Some(kind) = resolve_insert_kind(insert) { kinds.push(kind); } slot_idx += 1; @@ -220,52 +347,7 @@ impl View { } pub fn filter(&mut self, opt: &Opt, config: &Config, header_lines: usize) -> Result<(), Error> { - let mut cols_nonnumeric = Vec::new(); - let mut cols_numeric = Vec::new(); - let mut cols_searchable = Vec::new(); - for c in &self.columns { - if c.nonnumeric_search { - cols_nonnumeric.push(c.column.as_ref()); - cols_searchable.push(c.column.as_ref()); - } - if c.numeric_search { - cols_numeric.push(c.column.as_ref()); - if !c.nonnumeric_search { - cols_searchable.push(c.column.as_ref()); - } - } - } - - let mut keyword_nonnumeric = Vec::new(); - let mut keyword_numeric = Vec::new(); - - for k in &opt.keyword { - match classify(k) { - KeywordClass::Numeric => keyword_numeric.push(k), - KeywordClass::NonNumeric => keyword_nonnumeric.push(k), - } - } - - let regex_mode = if opt.regex { - true - } else if opt.smart { - opt.keyword.len() == 1 && has_regex_syntax(&opt.keyword[0]) - } else { - false - }; - - let regex = if regex_mode && !opt.keyword.is_empty() { - let pattern = &opt.keyword[0]; - let ignore_case = match config.search.case { - ConfigSearchCase::Smart => pattern == &pattern.to_ascii_lowercase(), - ConfigSearchCase::Insensitive => true, - ConfigSearchCase::Sensitive => false, - }; - let regex = SearchRegex::new(pattern, ignore_case)?; - Some(regex) - } else { - None - }; + let search = Search::new(self, opt, config)?; let pids = self.columns[self.sort_info.idx] .column @@ -290,42 +372,12 @@ impl View { Vec::new() }; - let logic = if opt.and { - ConfigSearchLogic::And - } else if opt.or { - ConfigSearchLogic::Or - } else if opt.nand { - ConfigSearchLogic::Nand - } else if opt.nor { - ConfigSearchLogic::Nor - } else { - config.search.logic.clone() - }; - let mut candidate_pids = Vec::new(); for pid in &pids { let hidden_process = (!config.display.show_self && *pid == self_pid) || (!config.display.show_self_parents && self_parents.contains(pid)); - let candidate = if hidden_process { - false - } else if opt.keyword.is_empty() { - true - } else if let Some(regex) = ®ex { - View::search_regex(*pid, cols_searchable.as_slice(), regex)? - } else { - View::search( - *pid, - &keyword_numeric, - &keyword_nonnumeric, - cols_numeric.as_slice(), - cols_nonnumeric.as_slice(), - config, - &logic, - ) - }; - - if candidate { + if !hidden_process && search.matches(*pid)? { candidate_pids.push(*pid); } } @@ -661,52 +713,71 @@ impl View { } } - fn search>( - pid: i32, - keyword_numeric: &[T], - keyword_nonnumeric: &[T], - cols_numeric: &[&dyn Column], - cols_nonnumeric: &[&dyn Column], + /// Iterate over the columns eligible for plain-keyword search. + fn searchable_columns(&self) -> impl Iterator { + self.columns + .iter() + .filter(|c| c.numeric_search || c.nonnumeric_search) + .map(|c| c.column.as_ref()) + } + + /// Borrow the first active column matching `kind`, if any. + fn column_for_kind(&self, kind: &ConfigColumnKind) -> Option<&dyn Column> { + self.columns + .iter() + .find(|c| &c.kind == kind) + .map(|c| c.column.as_ref()) + } + + /// Compile one raw keyword into a [`CompiledTerm`]. + /// + /// A plain numeric/nonnumeric keyword fans out to every searchable column + /// of its class, while a column predicate targets a single named column + /// regardless of that column's search flags. + fn compile_term<'a>( + &'a self, + keyword: &'a str, config: &Config, - logic: &ConfigSearchLogic, - ) -> bool { - let ret_nonnumeric = match config.search.nonnumeric_search { - ConfigSearchKind::Partial => find_partial( - cols_nonnumeric, - pid, - keyword_nonnumeric, - logic, - &config.search.case, - ), - ConfigSearchKind::Exact => find_exact( - cols_nonnumeric, - pid, - keyword_nonnumeric, - logic, - &config.search.case, - ), - }; - let ret_numeric = match config.search.numeric_search { - ConfigSearchKind::Partial => find_partial( - cols_numeric, - pid, - keyword_numeric, - logic, - &config.search.case, - ), - ConfigSearchKind::Exact => find_exact( - cols_numeric, - pid, - keyword_numeric, - logic, - &config.search.case, - ), - }; - match logic { - ConfigSearchLogic::And => ret_nonnumeric & ret_numeric, - ConfigSearchLogic::Or => ret_nonnumeric | ret_numeric, - ConfigSearchLogic::Nand => !(ret_nonnumeric & ret_numeric), - ConfigSearchLogic::Nor => !(ret_nonnumeric | ret_numeric), + ) -> Result, Error> { + match SearchTerm::parse(keyword)? { + SearchTerm::Plain(keyword) => { + let numeric = matches!(classify(keyword), KeywordClass::Numeric); + let search_kind = if numeric { + &config.search.numeric_search + } else { + &config.search.nonnumeric_search + }; + let cols = self + .columns + .iter() + .filter(|c| { + if numeric { + c.numeric_search + } else { + c.nonnumeric_search + } + }) + .map(|c| c.column.as_ref()) + .collect(); + Ok(CompiledTerm { + cols, + exact: matches!(search_kind, ConfigSearchKind::Exact), + needle: keyword, + }) + } + SearchTerm::Column(ColumnPredicate { kind, op, value }) => { + let col = self.column_for_kind(&kind).ok_or_else(|| { + let name = KIND_LIST[&kind].0; + anyhow::anyhow!( + "column \"{name}\" is not in the current layout; add it via config or --insert" + ) + })?; + Ok(CompiledTerm { + cols: vec![col], + exact: matches!(op, MatchOp::Exact), + needle: value, + }) + } } }