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