diff --git a/ProcessManager.App/CommandLine.cs b/ProcessManager.App/CommandLine.cs
index 1d35087..9457988 100644
--- a/ProcessManager.App/CommandLine.cs
+++ b/ProcessManager.App/CommandLine.cs
@@ -3,7 +3,7 @@
namespace Hawkynt.ProcessManager.App;
/// Which face of the program the arguments asked for.
-internal enum RunMode : byte { Desktop, Terminal, List, Find, Kill, SelfTest, HelperCheck, Help, Version }
+internal enum RunMode : byte { Desktop, Terminal, List, Find, Kill, SelfTest, HelperCheck, Help, HelpFields, Version }
///
/// The whole command line, parsed once into a value.
@@ -33,6 +33,41 @@ internal sealed record CommandLineOptions {
public bool KillTree { get; init; }
public int TargetPid { get; init; }
public string? Pattern { get; init; }
+
+ /// A filter in the query language of PRD §56, applied to --list and the two UIs.
+ public string? Filter { get; init; }
+
+ ///
+ /// Every field, printed from the registry rather than from a list kept alongside it — which is how
+ /// the old help text came to name ten sort keys when there were seventeen (PRD §5.1).
+ ///
+ public static string FieldHelpText {
+ get {
+ var text = new System.Text.StringBuilder();
+ text.AppendLine("Fields. Any of these can be used with --sort, --filter and --find,");
+ text.AppendLine("by the key or by any of its aliases.");
+ text.AppendLine();
+ text.AppendLine($" {"KEY",-20} {"ALIASES",-24} DESCRIPTION");
+ foreach (var descriptor in FieldRegistry.All) {
+ var aliases = descriptor.Aliases?.Replace(' ', ',') ?? "";
+ var note = descriptor.Platforms == FieldPlatforms.All
+ ? string.Empty
+ : $" [{descriptor.Platforms.ToString().Replace(", ", "/", StringComparison.Ordinal)} only]";
+
+ text.AppendLine($" {descriptor.Key,-20} {aliases,-24} {descriptor.Description}{note}");
+ }
+
+ text.AppendLine();
+ text.AppendLine("Filters: field:value field=value field>value field>=value field50 AND user:alice'");
+ text.AppendLine(" procman --filter 'memory:>1GiB NOT name:chrome'");
+ return text.ToString();
+ }
+ }
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(1);
/// Read a recorded /proc tree instead of the live one (PRD §9.1).
@@ -87,6 +122,19 @@ public static CommandLineOptions Parse(string[] args) {
options = options with { Mode = RunMode.List };
explicitMode = true;
break;
+ case "--filter": {
+ if (!TryValue(args, ref i, inlineValue, out var query))
+ return options with { Error = "--filter needs a query" };
+
+ // Parsed here rather than at first use so a typo is reported before the screen clears,
+ // and reported with the reason rather than as an empty list.
+ if (!ProcessQuery.TryParse(query, out _, out var problem))
+ return options with { Error = $"--filter: {problem}" };
+
+ options = options with { Filter = query };
+ break;
+ }
+
case "--find" or "-f": {
if (!TryValue(args, ref i, inlineValue, out var pattern))
return options with { Error = "--find needs a pattern" };
@@ -200,6 +248,9 @@ public static CommandLineOptions Parse(string[] args) {
options = options with { Mode = RunMode.HelperCheck };
explicitMode = true;
break;
+ case "--help-fields":
+ return options with { Mode = RunMode.HelpFields };
+
case "--help" or "-h" or "-?":
return options with { Mode = RunMode.Help };
case "--version" or "-V":
@@ -250,10 +301,12 @@ procman the desktop UI (falls back to the terminal with n
procman --tui the terminal UI
procman --list [--json] one snapshot to stdout, then exit
procman --find which processes match, by name, command line or open file
+ procman --help-fields every field that can be sorted, filtered or shown
procman --kill [--tree] end a process, optionally with its descendants
Options:
- --sort cpu, mem, pid, name, user, threads, read, write, start, handles
+ --sort any field key; see --help-fields for the list
+ --filter show only matching processes: 'cpu:>50', 'user:alice AND memory:>1GiB'
--tree show the process tree (with --kill: the whole subtree)
--flat start with a flat list sorted by CPU rather than a tree
--user only this user's processes
diff --git a/ProcessManager.App/Program.cs b/ProcessManager.App/Program.cs
index 900072e..ea0a263 100644
--- a/ProcessManager.App/Program.cs
+++ b/ProcessManager.App/Program.cs
@@ -29,6 +29,9 @@ private static int Main(string[] args) {
case RunMode.Help:
Console.WriteLine(CommandLineOptions.HelpText);
return _ExitOk;
+ case RunMode.HelpFields:
+ Console.Write(CommandLineOptions.FieldHelpText);
+ return _ExitOk;
case RunMode.Version:
Console.WriteLine($"procman {typeof(Program).Assembly.GetName().Version}");
return _ExitOk;
@@ -144,6 +147,7 @@ private static int RunList(Sampler sampler, CommandLineOptions options) {
SortColumn = options.SortColumn,
SortDescending = options.SortDescending,
TreeMode = options.TreeMode,
+ TextFilter = options.Filter,
};
view.Rebuild(sampler.Current, sampler.Delta);
diff --git a/ProcessManager.Core/Query/ProcessQuery.cs b/ProcessManager.Core/Query/ProcessQuery.cs
new file mode 100644
index 0000000..1b2c5eb
--- /dev/null
+++ b/ProcessManager.Core/Query/ProcessQuery.cs
@@ -0,0 +1,482 @@
+using System.Text.RegularExpressions;
+using Hawkynt.ProcessManager.Model;
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Query;
+
+/// How a term compares its value.
+public enum QueryOperator : byte {
+ Contains,
+ Equal,
+ NotEqual,
+ Greater,
+ GreaterOrEqual,
+ Less,
+ LessOrEqual,
+ Matches,
+}
+
+///
+/// The filter language, shared by the window, the terminal and the command line (PRD §56).
+///
+///
+/// One parser in Core, over the canonical field keys of , with no
+/// front-end permitted its own dialect — that is the requirement, and it is also why the registry is
+/// a data structure rather than a switch: every field added to it becomes filterable here for free.
+///
+/// A query is parsed once when the user stops typing and evaluated per process per sample, so
+/// parsing may allocate and matching may not.
+///
+///
+public sealed class ProcessQuery {
+
+ private readonly Node? _root;
+
+ private ProcessQuery(Node? root) => this._root = root;
+
+ /// Matches everything; what an empty search box means.
+ public static readonly ProcessQuery Empty = new(null);
+
+ public bool IsEmpty => this._root is null;
+
+ /// The query as it was typed, for redisplay.
+ public string Text { get; private init; } = string.Empty;
+
+ ///
+ /// Parses a query. A query that does not parse is reported rather than silently matching nothing,
+ /// because a filter that quietly hides every row looks exactly like a machine with no processes.
+ ///
+ public static bool TryParse(string? text, out ProcessQuery query, out string? error) {
+ error = null;
+ if (string.IsNullOrWhiteSpace(text)) {
+ query = Empty;
+ return true;
+ }
+
+ try {
+ var parser = new Parser(text);
+ var root = parser.ParseExpression();
+ parser.ExpectEnd();
+ query = new(root) { Text = text };
+ return true;
+ } catch (QueryException problem) {
+ query = Empty;
+ error = problem.Message;
+ return false;
+ }
+ }
+
+ /// Parses, falling back to a plain substring search on anything that will not parse.
+ ///
+ /// What an interactive search box wants: somebody typing "chrome:" has not written a broken query,
+ /// they are halfway through writing a working one, and blanking the list at every keystroke would
+ /// make the box unusable.
+ ///
+ public static ProcessQuery ParseOrSubstring(string? text) {
+ if (string.IsNullOrWhiteSpace(text))
+ return Empty;
+
+ return TryParse(text, out var query, out _)
+ ? query
+ : new(new FreeTextNode(text)) { Text = text };
+ }
+
+ public bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index)
+ => this._root is null || this._root.Matches(in process, delta, index);
+
+ #region the tree
+
+ private abstract class Node {
+ public abstract bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index);
+ }
+
+ private sealed class AndNode(Node left, Node right) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index)
+ => left.Matches(in process, delta, index) && right.Matches(in process, delta, index);
+ }
+
+ private sealed class OrNode(Node left, Node right) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index)
+ => left.Matches(in process, delta, index) || right.Matches(in process, delta, index);
+ }
+
+ private sealed class NotNode(Node inner) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index)
+ => !inner.Matches(in process, delta, index);
+ }
+
+ /// A bare word: matched against the fields somebody plausibly meant.
+ private sealed class FreeTextNode(string text) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index)
+ => Has(process.Name) || Has(process.CommandLine) || Has(process.UserName) || Has(process.ImagePath);
+
+ private bool Has(string? value) => value is not null && value.Contains(text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private sealed class RegexNode(Regex pattern, ProcessField? field) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index) {
+ if (field is { } one)
+ return FieldAccessor.RawText(one, in process) is { } text && pattern.IsMatch(text);
+
+ return Try(process.Name) || Try(process.CommandLine) || Try(process.ImagePath);
+ }
+
+ private bool Try(string? value) => value is not null && pattern.IsMatch(value);
+ }
+
+ private sealed class TextComparisonNode(ProcessField field, QueryOperator op, string value) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index) {
+ var text = FieldAccessor.RawText(field, in process);
+ if (text is null)
+ // A field with no text on this platform matches nothing — including "not equal", because
+ // "this process's container is not X" is not a claim we can make when there are no
+ // containers here at all (PRD §72.3).
+ return false;
+
+ return op switch {
+ QueryOperator.Contains => text.Contains(value, StringComparison.OrdinalIgnoreCase),
+ QueryOperator.Equal => string.Equals(text, value, StringComparison.OrdinalIgnoreCase),
+ QueryOperator.NotEqual => !string.Equals(text, value, StringComparison.OrdinalIgnoreCase),
+ _ => string.Compare(text, value, StringComparison.OrdinalIgnoreCase) switch {
+ var c => op switch {
+ QueryOperator.Greater => c > 0,
+ QueryOperator.GreaterOrEqual => c >= 0,
+ QueryOperator.Less => c < 0,
+ QueryOperator.LessOrEqual => c <= 0,
+ _ => false,
+ },
+ },
+ };
+ }
+ }
+
+ private sealed class NumberComparisonNode(ProcessField field, QueryOperator op, double value) : Node {
+ public override bool Matches(in ProcessRecord process, SnapshotDelta? delta, int index) {
+ // No number at all is not zero. A process whose memory could not be read matches neither
+ // "> 0" nor "== 0", which is the only honest answer (PRD §72.3).
+ if (FieldAccessor.Number(field, in process, delta, index) is not { } actual)
+ return false;
+
+ return op switch {
+ QueryOperator.Greater => actual > value,
+ QueryOperator.GreaterOrEqual => actual >= value,
+ QueryOperator.Less => actual < value,
+ QueryOperator.LessOrEqual => actual <= value,
+ QueryOperator.NotEqual => Math.Abs(actual - value) > Tolerance(value),
+ // Equality on a measured double is a trap: "cpu = 12.5" would never match a figure that is
+ // really 12.499999. Compare to the precision the value is displayed at.
+ _ => Math.Abs(actual - value) <= Tolerance(value),
+ };
+ }
+
+ private static double Tolerance(double value) => Math.Max(Math.Abs(value) * 1e-9, 0.05);
+ }
+
+ #endregion
+
+ #region parsing
+
+ private sealed class QueryException(string message) : Exception(message);
+
+ private sealed class Parser(string text) {
+
+ private int _position;
+
+ public Node ParseExpression() => this.ParseOr();
+
+ public void ExpectEnd() {
+ this.SkipSpace();
+ if (this._position < text.Length)
+ throw new QueryException($"unexpected '{text[this._position]}' at position {this._position}");
+ }
+
+ private Node ParseOr() {
+ var left = this.ParseAnd();
+ while (this.TryKeyword("OR") || this.TrySymbol("||"))
+ left = new OrNode(left, this.ParseAnd());
+
+ return left;
+ }
+
+ private Node ParseAnd() {
+ var left = this.ParseUnary();
+ while (true) {
+ if (this.TryKeyword("AND") || this.TrySymbol("&&")) {
+ left = new AndNode(left, this.ParseUnary());
+ continue;
+ }
+
+ // Two terms side by side mean AND, which is what every search box in the world does.
+ this.SkipSpace();
+ if (this._position >= text.Length || text[this._position] == ')'
+ || this.PeeksKeyword("OR") || this.Peeks("||"))
+ return left;
+
+ left = new AndNode(left, this.ParseUnary());
+ }
+ }
+
+ private Node ParseUnary() {
+ if (this.TryKeyword("NOT") || this.TrySymbol("!") || this.TrySymbol("-"))
+ return new NotNode(this.ParseUnary());
+
+ return this.ParsePrimary();
+ }
+
+ private Node ParsePrimary() {
+ this.SkipSpace();
+ if (this._position >= text.Length)
+ throw new QueryException("the query ends where a term was expected");
+
+ if (text[this._position] == '(') {
+ ++this._position;
+ var inner = this.ParseExpression();
+ this.SkipSpace();
+ if (this._position >= text.Length || text[this._position] != ')')
+ throw new QueryException("a '(' was never closed");
+
+ ++this._position;
+ return inner;
+ }
+
+ if (text[this._position] == '/')
+ return new RegexNode(this.ReadRegex(), null);
+
+ return this.ParseTerm();
+ }
+
+ private Node ParseTerm() {
+ var start = this._position;
+ var word = this.ReadWord();
+ if (word.Length == 0)
+ throw new QueryException($"expected a term at position {start}");
+
+ // A quoted term is always free text: "chrome AND" searches for that string, not for a query.
+ if (this._wasQuoted)
+ return new FreeTextNode(word);
+
+ if (!TrySplit(word, out var name, out var op, out var value)) {
+ // The operator may be detached from its field — "threads > 1" is what people actually type,
+ // and reading it as a search for the word "threads" would be silently wrong rather than
+ // loudly wrong.
+ if (!this.TryReadDetachedOperator(out op))
+ return new FreeTextNode(word);
+
+ name = word;
+ value = string.Empty;
+ }
+
+ if (!FieldRegistry.TryParse(name, out var field))
+ throw new QueryException($"there is no field called '{name}'");
+
+ // The value may be the next token: name:"foo bar", or cpu > 50 with spaces around it.
+ if (value.Length == 0) {
+ value = this.ReadWord();
+ if (value.Length == 0)
+ throw new QueryException($"'{name}' has no value to compare against");
+ }
+
+ // A value may carry its own operator, which is how "cpu:>50" reads.
+ if (!this._wasQuoted && TryLeadingOperator(value, out var inner, out var rest)) {
+ op = inner;
+ value = rest;
+ }
+
+ if (value.Length >= 2 && value[0] == '/' && value[^1] == '/')
+ return new RegexNode(Compile(value[1..^1]), field);
+
+ // Which comparison to use comes from the field's declared kind, not from guessing at the
+ // value: "pid:1234" is a number because a pid is an identifier, and "name:1234" is text
+ // because a name is text, even though both look like digits.
+ var descriptor = FieldRegistry.Get(field);
+ if (descriptor.Kind is FieldKind.Text or FieldKind.State)
+ return new TextComparisonNode(field, op, value);
+
+ if (!Quantity.TryParse(value, descriptor.Unit, out var number))
+ throw new QueryException($"'{value}' is not a number {descriptor.Header.ToLowerInvariant()} can be compared to");
+
+ return new NumberComparisonNode(field, op, number);
+ }
+
+ private bool _wasQuoted;
+
+ private Regex ReadRegex() {
+ ++this._position;
+ var start = this._position;
+ while (this._position < text.Length && text[this._position] != '/')
+ ++this._position;
+
+ if (this._position >= text.Length)
+ throw new QueryException("a regular expression was never closed with '/'");
+
+ var pattern = text[start..this._position];
+ ++this._position;
+ return Compile(pattern);
+ }
+
+ private static Regex Compile(string pattern) {
+ try {
+ // Interpreted, never compiled: RegexOptions.Compiled emits IL at run time, which NativeAOT
+ // cannot do (PRD §8.3). A timeout because a filter box is not a place to hang the UI.
+ return new(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50));
+ } catch (ArgumentException problem) {
+ throw new QueryException($"'{pattern}' is not a valid regular expression: {problem.Message}");
+ }
+ }
+
+ private string ReadWord() {
+ this.SkipSpace();
+ this._wasQuoted = false;
+ if (this._position >= text.Length)
+ return string.Empty;
+
+ var quote = text[this._position];
+ if (quote is '"' or '\'') {
+ ++this._position;
+ var from = this._position;
+ while (this._position < text.Length && text[this._position] != quote)
+ ++this._position;
+
+ if (this._position >= text.Length)
+ throw new QueryException("a quoted value was never closed");
+
+ var quoted = text[from..this._position];
+ ++this._position;
+ this._wasQuoted = true;
+ return quoted;
+ }
+
+ var start = this._position;
+ while (this._position < text.Length && !char.IsWhiteSpace(text[this._position])
+ && text[this._position] is not ('(' or ')')) {
+ // A quote inside a word ends the word's bare part: name:"foo bar" splits at the quote.
+ if (text[this._position] is '"' or '\'')
+ break;
+
+ ++this._position;
+ }
+
+ return text[start..this._position];
+ }
+
+ private void SkipSpace() {
+ while (this._position < text.Length && char.IsWhiteSpace(text[this._position]))
+ ++this._position;
+ }
+
+ private bool TrySymbol(string symbol) {
+ this.SkipSpace();
+ if (this._position + symbol.Length > text.Length
+ || !text.AsSpan(this._position, symbol.Length).SequenceEqual(symbol))
+ return false;
+
+ this._position += symbol.Length;
+ return true;
+ }
+
+ /// Whether the next symbol is this one, without consuming it.
+ private bool Peeks(string symbol) {
+ var saved = this._position;
+ var found = this.TrySymbol(symbol);
+ this._position = saved;
+ return found;
+ }
+
+ private bool PeeksKeyword(string keyword) {
+ var saved = this._position;
+ var found = this.TryKeyword(keyword);
+ this._position = saved;
+ return found;
+ }
+
+ private bool TryKeyword(string keyword) {
+ this.SkipSpace();
+ if (this._position + keyword.Length > text.Length
+ || !text.AsSpan(this._position, keyword.Length).Equals(keyword, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ // "ORacle" is a search term, not an OR; a keyword must be a whole word.
+ var after = this._position + keyword.Length;
+ if (after < text.Length && !char.IsWhiteSpace(text[after]) && text[after] is not ('(' or ')'))
+ return false;
+
+ this._position = after;
+ return true;
+ }
+
+ /// Splits cpu>50 into its name, its operator and whatever followed.
+ private static bool TrySplit(string word, out string name, out QueryOperator op, out string value) {
+ name = word;
+ op = QueryOperator.Contains;
+ value = string.Empty;
+
+ for (var i = 0; i < word.Length; ++i) {
+ var length = OperatorAt(word, i, out var found);
+ if (length == 0)
+ continue;
+
+ if (i == 0)
+ return false;
+
+ name = word[..i];
+ op = found;
+ value = word[(i + length)..];
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Consumes an operator standing on its own between a field and its value, so that
+ /// threads > 1 reads the same as threads>1.
+ ///
+ private bool TryReadDetachedOperator(out QueryOperator op) {
+ op = QueryOperator.Contains;
+ this.SkipSpace();
+ if (this._position >= text.Length)
+ return false;
+
+ var length = OperatorAt(text, this._position, out op);
+ if (length == 0)
+ return false;
+
+ this._position += length;
+ return true;
+ }
+
+ private static bool TryLeadingOperator(string value, out QueryOperator op, out string rest) {
+ var length = OperatorAt(value, 0, out op);
+ rest = length == 0 ? value : value[length..];
+ return length != 0 && rest.Length > 0;
+ }
+
+ private static int OperatorAt(string word, int index, out QueryOperator op) {
+ op = QueryOperator.Contains;
+ if (index >= word.Length)
+ return 0;
+
+ // Two-character operators first: ">=" must not be read as ">" followed by a value of "=50".
+ if (index + 1 < word.Length) {
+ switch (word[index], word[index + 1]) {
+ case ('>', '='): op = QueryOperator.GreaterOrEqual; return 2;
+ case ('<', '='): op = QueryOperator.LessOrEqual; return 2;
+ case ('!', '='): op = QueryOperator.NotEqual; return 2;
+ case ('=', '='): op = QueryOperator.Equal; return 2;
+ }
+ }
+
+ switch (word[index]) {
+ case ':': op = QueryOperator.Contains; return 1;
+ case '=': op = QueryOperator.Equal; return 1;
+ case '>': op = QueryOperator.Greater; return 1;
+ case '<': op = QueryOperator.Less; return 1;
+ default: return 0;
+ }
+ }
+
+ }
+
+ #endregion
+
+}
diff --git a/ProcessManager.Core/Query/ProcessView.cs b/ProcessManager.Core/Query/ProcessView.cs
index cab933d..2087e75 100644
--- a/ProcessManager.Core/Query/ProcessView.cs
+++ b/ProcessManager.Core/Query/ProcessView.cs
@@ -39,7 +39,28 @@ public sealed class ProcessView {
public bool TreeMode { get; set; }
/// Case-insensitive substring matched against name and command line; null for all.
- public string? TextFilter { get; set; }
+ ///
+ /// The filter, in the query language of PRD §56 — chrome, cpu:>50,
+ /// user:alice AND memory:>1GiB.
+ ///
+ ///
+ /// Anything that does not parse falls back to a plain substring search rather than matching
+ /// nothing, because somebody typing "chrome:" is halfway through a working query and blanking the
+ /// list at every keystroke makes the box unusable.
+ ///
+ public string? TextFilter {
+ get => this._filterText;
+ set {
+ this._filterText = value;
+ this._query = ProcessQuery.ParseOrSubstring(value);
+ }
+ }
+
+ private string? _filterText;
+ private ProcessQuery _query = ProcessQuery.Empty;
+
+ /// The parsed filter, so a caller can report what was wrong with it.
+ public ProcessQuery Query => this._query;
/// Show only this user's processes; null for every user.
public int? UserIdFilter { get; set; }
@@ -92,7 +113,7 @@ public void Rebuild(SystemSnapshot snapshot, SnapshotDelta delta) {
this._byPid[processes[i].Pid] = i;
for (var i = 0; i < count; ++i)
- this._visible[i] = this.Matches(processes[i]);
+ this._visible[i] = this.Matches(processes[i], i);
if (this.TreeMode)
this.BuildTree(processes, count);
@@ -187,7 +208,7 @@ private void LinkParents(ReadOnlySpan processes, int count) {
}
private void PromoteAncestorsOfMatches(int count) {
- if (this.TextFilter is null && this.UserIdFilter is null)
+ if (this._query.IsEmpty && this.UserIdFilter is null)
return;
// A process is shown when it matches, or when something under it does. Without this, filtering in
@@ -225,16 +246,11 @@ private void IndexChildren(int count) {
}
}
- private bool Matches(in ProcessRecord process) {
+ private bool Matches(in ProcessRecord process, int index) {
if (this.UserIdFilter is { } uid && process.UserId != uid)
return false;
- var filter = this.TextFilter;
- if (string.IsNullOrEmpty(filter))
- return true;
-
- return process.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)
- || (process.CommandLine?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
+ return this._query.Matches(in process, this._delta, index);
}
private int Compare(int left, int right) {
diff --git a/ProcessManager.Core/Query/Quantity.cs b/ProcessManager.Core/Query/Quantity.cs
new file mode 100644
index 0000000..b6f079a
--- /dev/null
+++ b/ProcessManager.Core/Query/Quantity.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+
+namespace Hawkynt.ProcessManager.Query;
+
+///
+/// Parses the numbers a human types into a filter: 1GiB, 500MB, 50%,
+/// 1.5s, 10k (PRD §56, §76).
+///
+///
+/// Unit-aware, and the unit it is aware of is the field's. 1G against a byte field is
+/// 1073741824 and against a count field is 1000000000, because a gigabyte and a billion context
+/// switches are different things and a filter that got that wrong by 7% would be quietly useless.
+/// Spelling it GiB or GB overrides the guess in the usual way.
+///
+public static class Quantity {
+
+ ///
+ /// Reads a quantity in the units of , returning it in the same units the
+ /// engine stores that field in — bytes, nanoseconds, percent or a plain count.
+ ///
+ public static bool TryParse(ReadOnlySpan text, FieldUnit unit, out double value) {
+ value = 0;
+ text = text.Trim();
+ if (text.IsEmpty)
+ return false;
+
+ // Split the digits from the suffix. Everything up to the last digit (or '.') is the number.
+ var end = 0;
+ while (end < text.Length && (char.IsAsciiDigit(text[end]) || text[end] is '.' or '-' or '+'))
+ ++end;
+
+ if (end == 0)
+ return false;
+
+ if (!double.TryParse(text[..end], NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
+ return false;
+
+ var suffix = text[end..].Trim();
+ if (suffix.IsEmpty) {
+ // A bare number is already in the field's own units, except for time: nobody types 5000000000
+ // when they mean five seconds.
+ value = unit == FieldUnit.Nanoseconds ? number * 1_000_000_000d : number;
+ return true;
+ }
+
+ if (suffix is "%" && unit is FieldUnit.Percent) {
+ value = number;
+ return true;
+ }
+
+ if (unit is FieldUnit.Nanoseconds && TryTimeScale(suffix, out var timeScale)) {
+ value = number * timeScale;
+ return true;
+ }
+
+ if (!TryMagnitude(suffix, unit, out var scale))
+ return false;
+
+ value = number * scale;
+ return true;
+ }
+
+ private static bool TryTimeScale(ReadOnlySpan suffix, out double scale) {
+ scale = suffix switch {
+ "ns" => 1d,
+ "us" or "µs" => 1_000d,
+ "ms" => 1_000_000d,
+ "s" or "sec" => 1_000_000_000d,
+ "m" or "min" => 60d * 1_000_000_000d,
+ "h" or "hr" => 3600d * 1_000_000_000d,
+ "d" => 86400d * 1_000_000_000d,
+ _ => 0d,
+ };
+
+ return scale > 0;
+ }
+
+ private static bool TryMagnitude(ReadOnlySpan suffix, FieldUnit unit, out double scale) {
+ // A trailing "/s" is noise on a rate field — "1MB/s" and "1MB" mean the same thing when the
+ // field is already per-second, and refusing the more natural spelling would be pedantry.
+ if (suffix.EndsWith("/s", StringComparison.OrdinalIgnoreCase))
+ suffix = suffix[..^2];
+
+ if (suffix.IsEmpty) {
+ scale = 1;
+ return true;
+ }
+
+ // Explicit spellings win: "KiB" is always 1024 and "kB" is always 1000, whatever the field is.
+ var binary = suffix.EndsWith("iB", StringComparison.OrdinalIgnoreCase);
+ var decimalSi = !binary && suffix.EndsWith("B", StringComparison.OrdinalIgnoreCase) && suffix.Length > 1;
+
+ var letter = char.ToUpperInvariant(suffix[0]);
+ if (suffix.Length == 1 && letter == 'B') {
+ scale = 1;
+ return true;
+ }
+
+ var power = letter switch {
+ 'K' => 1,
+ 'M' => 2,
+ 'G' => 3,
+ 'T' => 4,
+ 'P' => 5,
+ _ => 0,
+ };
+
+ if (power == 0) {
+ scale = 0;
+ return false;
+ }
+
+ // No explicit spelling: a byte field is binary and a count is decimal, because that is what each
+ // of them means everywhere else in the program (PRD §76).
+ var basis = binary ? 1024d
+ : decimalSi ? 1000d
+ : unit is FieldUnit.Bytes or FieldUnit.BytesPerSecond ? 1024d
+ : 1000d;
+
+ scale = Math.Pow(basis, power);
+ return true;
+ }
+
+}
diff --git a/ProcessManager.Tests/ProcessQueryTests.cs b/ProcessManager.Tests/ProcessQueryTests.cs
new file mode 100644
index 0000000..88fd0bd
--- /dev/null
+++ b/ProcessManager.Tests/ProcessQueryTests.cs
@@ -0,0 +1,336 @@
+using Hawkynt.ProcessManager.Model;
+using Hawkynt.ProcessManager.Query;
+using Hawkynt.ProcessManager.Sampling;
+
+namespace Hawkynt.ProcessManager.Tests;
+
+///
+/// The filter language (PRD §56). One parser in Core, so what is asserted here is what the window,
+/// the terminal and the command line all do.
+///
+[TestFixture]
+public sealed class ProcessQueryTests {
+
+ private static SystemSnapshot _snapshot = null!;
+ private static SnapshotDelta _delta = null!;
+
+ [OneTimeSetUp]
+ public void BuildSnapshot() {
+ _snapshot = new();
+ var records = _snapshot.PrepareProcesses(3);
+
+ records[0] = default;
+ records[0].Key = new(100, 1);
+ records[0].Name = "chrome";
+ records[0].UserName = "alice";
+ records[0].UserId = 1000;
+ records[0].ParentPid = 1;
+ records[0].State = ProcessState.Sleeping;
+ records[0].ThreadCount = 42;
+ records[0].CommandLine = "/opt/chrome/chrome --type=renderer";
+ records[0].ImagePath = "/opt/chrome/chrome";
+ records[0].PrivateBytes = Counter.Of(2ul * 1024 * 1024 * 1024);
+ records[0].WorkingSetBytes = Counter.Of(512ul * 1024 * 1024);
+
+ records[1] = default;
+ records[1].Key = new(200, 2);
+ records[1].Name = "sshd";
+ records[1].UserName = "root";
+ records[1].UserId = 0;
+ records[1].State = ProcessState.Running;
+ records[1].ThreadCount = 1;
+ records[1].CommandLine = "/usr/sbin/sshd -D";
+ records[1].ImagePath = "/usr/sbin/sshd";
+ records[1].PrivateBytes = Counter.Of(8ul * 1024 * 1024);
+ records[1].WorkingSetBytes = Counter.Of(4ul * 1024 * 1024);
+
+ // The third has no memory reading at all — the case that separates "zero" from "unknown".
+ records[2] = default;
+ records[2].Key = new(300, 3);
+ records[2].Name = "kthreadd";
+ records[2].UserName = "root";
+ records[2].UserId = 0;
+ records[2].State = ProcessState.Sleeping;
+ records[2].ThreadCount = 1;
+ records[2].PrivateBytes = Counter.NotSupported;
+ records[2].WorkingSetBytes = Counter.Of(0ul);
+
+ _delta = new();
+ _delta.Update(null, _snapshot, CpuPercentMode.Normalized);
+ }
+
+ private static List Match(string query) {
+ Assert.That(ProcessQuery.TryParse(query, out var parsed, out var error), Is.True, error);
+ var names = new List();
+ var processes = _snapshot.Processes;
+ for (var i = 0; i < processes.Length; ++i)
+ if (parsed.Matches(in processes[i], _delta, i))
+ names.Add(processes[i].Name);
+
+ return names;
+ }
+
+ #region free text
+
+ [Test]
+ public void ABareWordSearchesTheFieldsSomebodyPlausiblyMeant() {
+ Assert.That(Match("chrome"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("renderer"), Is.EqualTo(new[] { "chrome" }), "the command line");
+ Assert.That(Match("alice"), Is.EqualTo(new[] { "chrome" }), "the user");
+ Assert.That(Match("/usr/sbin"), Is.EqualTo(new[] { "sshd" }), "the image path");
+ }
+
+ [Test]
+ public void FreeTextIsCaseInsensitive() => Assert.That(Match("CHROME"), Is.EqualTo(new[] { "chrome" }));
+
+ [Test]
+ public void AQuotedTermIsAlwaysFreeTextEvenWhenItLooksLikeAQuery() {
+ // Otherwise there is no way to search for a literal string containing a colon or an operator.
+ Assert.That(Match("\"name:chrome\""), Is.Empty, "quoted, so it is a literal and matches nothing");
+ Assert.That(Match("name:chrome"), Is.EqualTo(new[] { "chrome" }), "unquoted, so it is a comparison");
+ Assert.That(Match("\"--type=renderer\""), Is.EqualTo(new[] { "chrome" }), "a literal with an '=' in it");
+ }
+
+ #endregion
+
+ #region fields
+
+ [Test]
+ public void AFieldCanBeComparedByItsKey() {
+ Assert.That(Match("pid:200"), Is.EqualTo(new[] { "sshd" }));
+ Assert.That(Match("user:root"), Is.EqualTo(new[] { "sshd", "kthreadd" }));
+ Assert.That(Match("threads:42"), Is.EqualTo(new[] { "chrome" }));
+ }
+
+ [Test]
+ public void AFieldCanBeComparedByAnAlias() {
+ // "memory" is an alias of "private", declared once in the registry and honoured everywhere.
+ Assert.That(Match("memory:>1GiB"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("rss:>100MiB"), Is.EqualTo(new[] { "chrome" }));
+ }
+
+ [Test]
+ public void ComparisonOperatorsWork() {
+ Assert.That(Match("threads:>1"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("threads:>=1"), Is.EqualTo(new[] { "chrome", "sshd", "kthreadd" }));
+ Assert.That(Match("threads:<2"), Is.EqualTo(new[] { "sshd", "kthreadd" }));
+ Assert.That(Match("threads:<=1"), Is.EqualTo(new[] { "sshd", "kthreadd" }));
+ Assert.That(Match("pid=200"), Is.EqualTo(new[] { "sshd" }));
+ Assert.That(Match("pid!=200"), Is.EqualTo(new[] { "chrome", "kthreadd" }));
+ }
+
+ [Test]
+ public void TheOperatorMayFollowTheColonOrReplaceIt() {
+ // "cpu:>50" is the spelling in the PRD; "cpu>50" is what people type. Both must work.
+ Assert.That(Match("threads:>1"), Is.EqualTo(Match("threads>1")));
+ Assert.That(Match("threads:>=1"), Is.EqualTo(Match("threads>=1")));
+ }
+
+ [Test]
+ public void SpacesAroundAnOperatorAreAllowed() {
+ Assert.That(Match("threads > 1"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("user : root"), Is.EqualTo(new[] { "sshd", "kthreadd" }));
+ }
+
+ [Test]
+ public void TextFieldsMatchBySubstringButEqualsIsExact() {
+ Assert.That(Match("name:chr"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("name=chr"), Is.Empty);
+ Assert.That(Match("name=chrome"), Is.EqualTo(new[] { "chrome" }));
+ }
+
+ [Test]
+ public void StateIsMatchedByItsDisplayedName() => Assert.That(Match("state:sleep"), Is.EqualTo(new[] { "chrome", "kthreadd" }));
+
+ ///
+ /// The distinction the whole engine is built on: kthreadd's private bytes are unknown, not zero,
+ /// so it matches neither side of the comparison (PRD §72.3).
+ ///
+ [Test]
+ public void AnUnknownValueMatchesNeitherGreaterThanZeroNorEqualToZero() {
+ Assert.That(Match("private:>0"), Is.EqualTo(new[] { "chrome", "sshd" }));
+ Assert.That(Match("private:0"), Is.Empty, "kthreadd's memory is unknown, and unknown is not zero");
+ Assert.That(Match("ws:0"), Is.EqualTo(new[] { "kthreadd" }), "…but a real zero still matches");
+ }
+
+ [Test]
+ public void AnUnknownValueDoesNotMatchNotEqualEither() =>
+ // "not equal to 5" is a claim about a value we do not have, and we will not make it.
+ Assert.That(Match("private!=5"), Is.EqualTo(new[] { "chrome", "sshd" }));
+
+ #endregion
+
+ #region boolean structure
+
+ [Test]
+ public void TermsSideBySideMeanAnd() {
+ Assert.That(Match("user:root state:sleep"), Is.EqualTo(new[] { "kthreadd" }));
+ Assert.That(Match("user:root AND state:sleep"), Is.EqualTo(new[] { "kthreadd" }));
+ Assert.That(Match("user:root && state:sleep"), Is.EqualTo(new[] { "kthreadd" }));
+ }
+
+ [Test]
+ public void OrWorks() {
+ Assert.That(Match("name:chrome OR name:sshd"), Is.EqualTo(new[] { "chrome", "sshd" }));
+ Assert.That(Match("name:chrome || name:sshd"), Is.EqualTo(new[] { "chrome", "sshd" }));
+ }
+
+ [Test]
+ public void NotWorks() {
+ Assert.That(Match("NOT user:root"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("!user:root"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("-user:root"), Is.EqualTo(new[] { "chrome" }));
+ }
+
+ [Test]
+ public void AndBindsTighterThanOr() {
+ // "a AND b OR c" is "(a AND b) OR c", the way every language of this shape works.
+ Assert.That(Match("user:root AND state:run OR name:chrome"), Is.EqualTo(new[] { "chrome", "sshd" }));
+ Assert.That(Match("user:root AND (state:run OR name:chrome)"), Is.EqualTo(new[] { "sshd" }));
+ }
+
+ [Test]
+ public void ParenthesesGroup() =>
+ Assert.That(Match("(name:chrome OR name:sshd) AND user:root"), Is.EqualTo(new[] { "sshd" }));
+
+ /// A keyword must be a whole word, or "ORacle" would be an OR followed by "acle".
+ [Test]
+ public void AWordMerelyStartingWithAKeywordIsNotOne() {
+ Assert.That(ProcessQuery.TryParse("ORacle", out var query, out var error), Is.True, error);
+ Assert.That(query.IsEmpty, Is.False);
+ Assert.That(Match("ORacle"), Is.Empty, "it is a search for the string 'ORacle'");
+ }
+
+ #endregion
+
+ #region regular expressions
+
+ [Test]
+ public void ABareRegexSearchesTheUsualFields() => Assert.That(Match("/chr.me/"), Is.EqualTo(new[] { "chrome" }));
+
+ [Test]
+ public void ARegexCanBeAppliedToOneField() {
+ Assert.That(Match("name:/^ssh/"), Is.EqualTo(new[] { "sshd" }));
+ Assert.That(Match("name:/^shd/"), Is.Empty);
+ }
+
+ [Test]
+ public void AnInvalidRegexIsReportedRatherThanThrown() {
+ Assert.That(ProcessQuery.TryParse("/[unclosed/", out _, out var error), Is.False);
+ Assert.That(error, Does.Contain("regular expression"));
+ }
+
+ #endregion
+
+ #region errors
+
+ [Test]
+ public void AnUnknownFieldIsNamedInTheError() {
+ Assert.That(ProcessQuery.TryParse("bogus:1", out _, out var error), Is.False);
+ Assert.That(error, Does.Contain("bogus"));
+ }
+
+ [Test]
+ public void UnbalancedSyntaxIsReported() {
+ Assert.That(ProcessQuery.TryParse("(name:chrome", out _, out var openParen), Is.False);
+ Assert.That(openParen, Does.Contain("never closed"));
+
+ Assert.That(ProcessQuery.TryParse("name:\"chrome", out _, out var openQuote), Is.False);
+ Assert.That(openQuote, Does.Contain("never closed"));
+ }
+
+ [Test]
+ public void AValueThatIsNotANumberIsReported() {
+ Assert.That(ProcessQuery.TryParse("threads:lots", out _, out var error), Is.False);
+ Assert.That(error, Does.Contain("lots"));
+ }
+
+ ///
+ /// An interactive box must not blank the list while somebody is still typing, so a half-written
+ /// query degrades to a substring search rather than to nothing.
+ ///
+ [Test]
+ public void AHalfTypedQueryFallsBackToSubstringSearch() {
+ var query = ProcessQuery.ParseOrSubstring("chrome:");
+ Assert.That(query.IsEmpty, Is.False);
+
+ var processes = _snapshot.Processes;
+ var matched = new List();
+ for (var i = 0; i < processes.Length; ++i)
+ if (query.Matches(in processes[i], _delta, i))
+ matched.Add(processes[i].Name);
+
+ Assert.That(matched, Is.Empty, "'chrome:' is not a substring of anything here");
+ Assert.That(ProcessQuery.ParseOrSubstring("chrom").IsEmpty, Is.False);
+ }
+
+ [Test]
+ public void AnEmptyQueryMatchesEverything() {
+ Assert.That(ProcessQuery.TryParse("", out var empty, out _), Is.True);
+ Assert.That(empty.IsEmpty, Is.True);
+ Assert.That(ProcessQuery.TryParse(" ", out var blank, out _), Is.True);
+ Assert.That(blank.IsEmpty, Is.True);
+ }
+
+ #endregion
+
+ #region units
+
+ [TestCase("1KiB", FieldUnit.Bytes, 1024d)]
+ [TestCase("1kB", FieldUnit.Bytes, 1000d)]
+ [TestCase("1K", FieldUnit.Bytes, 1024d)]
+ [TestCase("1MiB", FieldUnit.Bytes, 1048576d)]
+ [TestCase("1GiB", FieldUnit.Bytes, 1073741824d)]
+ [TestCase("1GB", FieldUnit.Bytes, 1000000000d)]
+ [TestCase("1.5K", FieldUnit.Bytes, 1536d)]
+ [TestCase("512", FieldUnit.Bytes, 512d)]
+ [TestCase("1B", FieldUnit.Bytes, 1d)]
+ [TestCase("1MB/s", FieldUnit.BytesPerSecond, 1000000d)]
+ [TestCase("50", FieldUnit.Percent, 50d)]
+ [TestCase("50%", FieldUnit.Percent, 50d)]
+ public void BytesAreParsedWithTheRightBase(string text, FieldUnit unit, double expected) {
+ Assert.That(Quantity.TryParse(text, unit, out var value), Is.True, text);
+ Assert.That(value, Is.EqualTo(expected).Within(0.001), text);
+ }
+
+ ///
+ /// The reason the parser is unit-aware at all: a thousand of a count is 1000, and a thousand bytes
+ /// is 1024. Getting this wrong is a 2.4% error at K and 7.4% at G.
+ ///
+ [TestCase("1k", FieldUnit.Count, 1000d)]
+ [TestCase("1M", FieldUnit.Count, 1000000d)]
+ [TestCase("1G", FieldUnit.CountPerSecond, 1000000000d)]
+ public void CountsScaleInThousandsNotIn1024s(string text, FieldUnit unit, double expected) {
+ Assert.That(Quantity.TryParse(text, unit, out var value), Is.True, text);
+ Assert.That(value, Is.EqualTo(expected).Within(0.001), text);
+ }
+
+ [TestCase("5s", 5_000_000_000d)]
+ [TestCase("500ms", 500_000_000d)]
+ [TestCase("1m", 60_000_000_000d)]
+ [TestCase("2h", 7_200_000_000_000d)]
+ [TestCase("5", 5_000_000_000d)]
+ public void TimesAreParsedIntoNanoseconds(string text, double expected) {
+ Assert.That(Quantity.TryParse(text, FieldUnit.Nanoseconds, out var value), Is.True, text);
+ Assert.That(value, Is.EqualTo(expected).Within(0.001), text);
+ }
+
+ [TestCase("")]
+ [TestCase("lots")]
+ [TestCase("KiB")]
+ [TestCase("1ZB")]
+ public void NonsenseIsRefused(string text)
+ => Assert.That(Quantity.TryParse(text, FieldUnit.Bytes, out _), Is.False, text);
+
+ [Test]
+ public void AUnitAwareQuantityReachesTheComparison() {
+ // chrome has 2 GiB private; sshd has 8 MiB. The boundary must land between them either way it
+ // is spelled.
+ Assert.That(Match("private:>1GiB"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("private:>1073741823"), Is.EqualTo(new[] { "chrome" }));
+ Assert.That(Match("private:>1MiB"), Is.EqualTo(new[] { "chrome", "sshd" }));
+ }
+
+ #endregion
+
+}
diff --git a/ProcessManager.Tests/ProcessViewTests.cs b/ProcessManager.Tests/ProcessViewTests.cs
index 59f3e7b..d9ce5d2 100644
--- a/ProcessManager.Tests/ProcessViewTests.cs
+++ b/ProcessManager.Tests/ProcessViewTests.cs
@@ -179,4 +179,41 @@ private static int[] Depths(ProcessView view) {
#endregion
+
+ ///
+ /// The view filters through the shared parser, so the window's search box, the terminal's and
+ /// --filter all behave identically (PRD §56, §58).
+ ///
+ [Test]
+ public void TheViewFiltersWithTheQueryLanguage() {
+ var snapshot = new SystemSnapshot();
+ var records = snapshot.PrepareProcesses(2);
+ for (var i = 0; i < 2; ++i) {
+ records[i] = default;
+ records[i].Key = new(i + 1, (ulong)(i + 1));
+ records[i].ThreadCount = i == 0 ? 40 : 1;
+ }
+
+ records[0].Name = "chrome";
+ records[1].Name = "sshd";
+
+ var delta = new SnapshotDelta();
+ delta.Update(null, snapshot, CpuPercentMode.Normalized);
+
+ var view = new ProcessView { TextFilter = "threads:>10" };
+ view.Rebuild(snapshot, delta);
+ Assert.That(view.RowCount, Is.EqualTo(1));
+ Assert.That(snapshot.Processes[view.Rows[0].Index].Name, Is.EqualTo("chrome"));
+
+ // And a half-typed query must not blank the list, it must degrade to a substring search.
+ view.TextFilter = "ssh";
+ view.Rebuild(snapshot, delta);
+ Assert.That(view.RowCount, Is.EqualTo(1));
+ Assert.That(snapshot.Processes[view.Rows[0].Index].Name, Is.EqualTo("sshd"));
+
+ view.TextFilter = null;
+ view.Rebuild(snapshot, delta);
+ Assert.That(view.RowCount, Is.EqualTo(2));
+ }
+
}
diff --git a/docs/PRD.md b/docs/PRD.md
index 056a22d..72ed07e 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -31,8 +31,8 @@ shorthand:
it is not known*. An unticked box must never become a zero on screen. This is restated here because
it is the single requirement most likely to be broken while filling the tables in.
-**Counting, as of the last update:** **382 of 1250 boxes are ticked** — 55 of 189 in the field
-registry (§14–22), 327 of 1061 across the capabilities. A further 114 are marked 🟡, meaning some of
+**Counting, as of the last update:** **398 of 1250 boxes are ticked** — 55 of 189 in the field
+registry (§14–22), 343 of 1061 across the capabilities. A further 112 are marked 🟡, meaning some of
the work behind them is already done. §100 tracks the phases; §101 defines when this may be called
finished.
@@ -207,7 +207,7 @@ Each registry entry declares:
- [x] Collection cost
- [x] Default visibility
- [x] Sort semantics
-- [ ] 🟡 Filter semantics — `Number` and `RawText` are there; the query language of §56 is not
+- [x] Filter semantics — `Number` for comparisons, `RawText` for substring and regex
- [x] Formatting function
- [x] Null/unavailable semantics
- [ ] Export serialisation
@@ -481,11 +481,11 @@ Every table:
- [ ] Copy selected rows / columns
- [ ] Export table
- [x] Text filter
-- [ ] Advanced filter
-- [ ] Regular-expression filter
-- [ ] Numeric comparison filters
-- [ ] Unit-aware comparison
-- [ ] Case-sensitive toggle
+- [x] Advanced filter
+- [x] Regular-expression filter
+- [x] Numeric comparison filters
+- [x] Unit-aware comparison
+- [ ] Case-sensitive toggle — everything matches case-insensitively today
- [ ] Highlight matched text
- [ ] Multi-selection
- [ ] Select all / invert selection
@@ -1545,17 +1545,15 @@ Substring matching over name, PID, user and command line works in both front-end
language does not exist.
- [x] Plain substring search
-- [x] Every field is addressable by a stable key, which is the half of the query language the
- registry supplies — `--sort=private.ws` and `--sort=faults.delta` work without either having
- been written down anywhere as a sort key
-- [ ] `field:value`
-- [ ] `field=value`
-- [ ] Comparison operators
-- [ ] Quoted strings
-- [ ] Boolean AND / OR / NOT
-- [ ] Regex form
-- [ ] Unit-aware quantities
-- [ ] Search over hidden as well as visible fields
+- [x] Every field is addressable by a stable key
+- [x] `field:value`
+- [x] `field=value`
+- [x] Comparison operators — `>` `>=` `<` `<=` `!=`, with or without the colon, spaced or not
+- [x] Quoted strings — always literal, so a search for `name:chrome` as *text* is possible
+- [x] Boolean AND / OR / NOT — words or `&&` `||` `!`, with `(` `)`, and AND binding tighter than OR
+- [x] Regex form — `/pattern/` over the usual fields, `field:/pattern/` over one
+- [x] Unit-aware quantities — `1GiB` `500MB` `1K` `50%` `500ms` `1.5s`
+- [x] Search over hidden as well as visible fields — every registered field, shown or not
Examples that must parse:
@@ -1565,10 +1563,21 @@ memory:>1GiB port:443 remote:10.0.0.5 unsigned:true
path:/opt/myapp service:sshd state:suspended runtime:dotnet
```
-- [ ] **The same query syntax works in GUI, TUI and CLI.** This is a constraint on where the parser
- lives: in `ProcessManager.Core`, over the canonical field IDs of §14–22, with no front-end
- permitted its own dialect. It is also why the registry is a data structure rather than a switch
- statement — every field added to it becomes searchable for free.
+- [x] **The same query syntax works in GUI, TUI and CLI.** `ProcessQuery` lives in
+ `ProcessManager.Core` and every front-end filters through `ProcessView.TextFilter`, so no
+ front-end has its own dialect. Every field added to the registry becomes filterable for free.
+
+Two decisions worth recording, because both could reasonably have gone the other way:
+
+- **A half-typed query degrades to a substring search rather than matching nothing.** Somebody typing
+ `chrome:` is midway through a working query, and blanking the list at every keystroke makes an
+ interactive box unusable. `--filter` is the opposite: it refuses the query and names the problem,
+ because a script that silently matched nothing would be worse than one that stopped.
+- **An unknown value matches no comparison at all** — not `> 0`, not `== 0`, and not `!= 5`. Saying
+ a process's memory is "not equal to 5" is a claim about a number we do not have (§72.3).
+
+The unit is taken from the field, which is why `1G` is 1073741824 against a byte field and
+1000000000 against a count. Spelling it `GiB` or `GB` overrides the guess.
---
@@ -1672,7 +1681,8 @@ drag and drop.
- [x] `procman ps`
- [x] `procman ps --tree`
- [ ] 🟡 `procman ps --columns pid,name,cpu,memory`
-- [ ] `procman ps --filter 'cpu > 50'`
+- [x] `procman ps --filter 'cpu > 50'` — as `--filter`, plus `--help-fields` listing every
+ field, its aliases and the filter grammar, generated from the registry so it cannot drift
- [ ] `procman process 1234`
- [ ] `procman process 1234 threads`
- [ ] `procman process 1234 modules`
@@ -2455,7 +2465,7 @@ v1 does not ship unless every one of these is true:
- [ ] The user can inspect logged-in sessions
- [ ] 🟡 The user can view CPU, memory, disk and network performance
- [ ] The user can create and restore column presets
-- [ ] 🟡 The user can search and filter by any registered visible field
+- [x] The user can search and filter by any registered field, visible or not
- [ ] 🟡 Tables remain usable with thousands of changing rows
- [x] Privileged actions work through the privilege broker
- [x] Lack of privileges does not crash or freeze views