Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<column><operator><value>` 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.
Expand Down
4 changes: 4 additions & 0 deletions man/procs.1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod columns;
mod config;
mod opt;
mod process;
mod search;
mod search_regex;
mod style;
mod term_info;
Expand Down Expand Up @@ -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();
Expand Down
126 changes: 126 additions & 0 deletions src/search.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Error> {
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());
}
}
1 change: 0 additions & 1 deletion src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ pub fn find_column_kind(pat: &str) -> Option<ConfigColumnKind> {
return Some(k.clone());
}
}
eprintln!("Can't find column kind: {pat}");
None
}

Expand Down
Loading