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, + }) + } } }