From d7a9e48d4846729e4bc773d2284519eaaf0cbb6e Mon Sep 17 00:00:00 2001 From: Warp Agent Date: Wed, 5 Aug 2026 22:42:18 +0000 Subject: [PATCH] Add completion spec: pkill `pkill` had no command signature, so Warp's argument completer fell back to filesystem paths instead of offering running process names (GH #10924). Add a `pkill` spec whose pattern argument is driven by a process-name generator, and move that generator (plus the signal-name generator `pkill` reuses for `--signal`) into `common.rs`, per the repo's generator-reuse convention. The shared process-name generator suppresses the `ps` header, reduces macOS's absolute executable paths to basenames, keeps Linux's bare names, and de-duplicates, so `killall` now also produces suggestions on Linux. Co-Authored-By: Warp Agent --- command-signatures/json/pkill.json | 253 ++++++++++++++++++ command-signatures/src/generators/common.rs | 62 +++++ command-signatures/src/generators/kill.rs | 17 +- command-signatures/src/generators/killall.rs | 29 +- command-signatures/src/generators/mod.rs | 4 + command-signatures/src/generators/pkill.rs | 10 + .../src/generators/pkill_tests.rs | 100 +++++++ 7 files changed, 434 insertions(+), 41 deletions(-) create mode 100644 command-signatures/json/pkill.json create mode 100644 command-signatures/src/generators/pkill.rs create mode 100644 command-signatures/src/generators/pkill_tests.rs diff --git a/command-signatures/json/pkill.json b/command-signatures/json/pkill.json new file mode 100644 index 00000000..580d2384 --- /dev/null +++ b/command-signatures/json/pkill.json @@ -0,0 +1,253 @@ +{ + "name": "pkill", + "description": "Signal processes selected by name and other attributes", + "args": { + "name": "pattern", + "description": "Extended regular expression matched against the process name (or the full command line with -f)", + "generatorName": "process_name" + }, + "options": [ + { + "name": "--signal", + "description": "Signal to send instead of TERM, as a name or number (procps-ng)", + "args": { + "name": "sig", + "generatorName": "signal_name" + } + }, + { + "name": [ + "-f", + "--full" + ], + "description": "Match against the full command line instead of just the process name" + }, + { + "name": [ + "-x", + "--exact" + ], + "description": "Match only processes whose name exactly equals the pattern" + }, + { + "name": [ + "-i", + "--ignore-case" + ], + "description": "Match case insensitively" + }, + { + "name": [ + "-n", + "--newest" + ], + "description": "Select only the most recently started matching process" + }, + { + "name": [ + "-o", + "--oldest" + ], + "description": "Select only the least recently started matching process" + }, + { + "name": [ + "-g", + "--pgroup" + ], + "description": "Match only processes in the listed process group IDs", + "args": { + "name": "PGID,..." + } + }, + { + "name": [ + "-G", + "--group" + ], + "description": "Match only processes whose real group ID is listed", + "args": { + "name": "GID,..." + } + }, + { + "name": [ + "-P", + "--parent" + ], + "description": "Match only child processes of the listed parents", + "args": { + "name": "PPID,..." + } + }, + { + "name": [ + "-s", + "--session" + ], + "description": "Match only processes in the listed session IDs", + "args": { + "name": "SID,..." + } + }, + { + "name": [ + "-t", + "--terminal" + ], + "description": "Match only processes attached to the listed controlling terminals", + "args": { + "name": "tty,..." + } + }, + { + "name": [ + "-u", + "--euid" + ], + "description": "Match only processes whose effective user ID is listed", + "args": { + "name": "user,...", + "generatorName": "user_name" + } + }, + { + "name": [ + "-U", + "--uid" + ], + "description": "Match only processes whose real user ID is listed", + "args": { + "name": "user,...", + "generatorName": "user_name" + } + }, + { + "name": [ + "-F", + "--pidfile" + ], + "description": "Restrict matches to the PIDs read from the given file", + "args": { + "name": "file", + "template": ["filepaths"] + } + }, + { + "name": [ + "-L", + "--logpidfile" + ], + "description": "Fail if the pidfile given with -F is not locked" + }, + { + "name": [ + "-c", + "--count" + ], + "description": "Print the number of matching processes (procps-ng)" + }, + { + "name": [ + "-e", + "--echo" + ], + "description": "Display the name and PID of each process signalled (procps-ng)" + }, + { + "name": [ + "-q", + "--queue" + ], + "description": "Integer value sent with the signal via sigqueue (procps-ng)", + "args": { + "name": "value" + } + }, + { + "name": [ + "-H", + "--require-handler" + ], + "description": "Match only processes with a handler installed for the signal (procps-ng)" + }, + { + "name": [ + "-O", + "--older" + ], + "description": "Match only processes started more than the given number of seconds ago (procps-ng)", + "args": { + "name": "seconds" + } + }, + { + "name": [ + "-r", + "--runstates" + ], + "description": "Match only processes in the given run states, e.g. D,S,Z (procps-ng)", + "args": { + "name": "D,R,S,Z,..." + } + }, + { + "name": [ + "-A", + "--ignore-ancestors" + ], + "description": "Exclude this process' ancestors from the matches (procps-ng)" + }, + { + "name": "--cgroup", + "description": "Match only processes in the listed cgroup v2 names (procps-ng)", + "args": { + "name": "grp,..." + } + }, + { + "name": "--ns", + "description": "Match only processes in the same namespaces as the given PID (procps-ng)", + "args": { + "name": "PID" + } + }, + { + "name": "--nslist", + "description": "Namespaces considered by --ns: ipc, mnt, net, pid, user, uts (procps-ng)", + "args": { + "name": "ns,..." + } + }, + { + "name": "-a", + "description": "Include this process' ancestors in the matches (BSD/macOS)" + }, + { + "name": "-I", + "description": "Request confirmation before signalling each process (BSD/macOS)" + }, + { + "name": "-S", + "description": "Search for matches among system processes (BSD/macOS)" + }, + { + "name": "-v", + "description": "Select processes that do not match the pattern (BSD/macOS)" + }, + { + "name": [ + "-h", + "--help" + ], + "description": "Display help and exit" + }, + { + "name": [ + "-V", + "--version" + ], + "description": "Display version information and exit" + } + ] +} diff --git a/command-signatures/src/generators/common.rs b/command-signatures/src/generators/common.rs index e5749718..919d8f2a 100644 --- a/command-signatures/src/generators/common.rs +++ b/command-signatures/src/generators/common.rs @@ -1,3 +1,5 @@ +use lazy_static::lazy_static; +use regex::Regex; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use warp_completion_metadata::{ @@ -160,6 +162,62 @@ pub fn systemd_user_units_generator() -> Generator { Generator::script(systemd_units_command(true), systemd_units) } +/// Parses `ps -o comm` output into suggestions naming the running executables. +/// +/// macOS reports absolute executable paths where Linux reports bare names, so each +/// line is reduced to its basename, which is what process-name matching expects. +/// A header row is dropped for the `ps` implementations that print one even when +/// the `comm=` format asks for none. +pub fn process_names(output: &str) -> GeneratorResults { + let mut seen = HashSet::new(); + output + .lines() + .filter_map(|line| { + let path = line.trim(); + if path.is_empty() || path == "COMM" || path == "COMMAND" { + return None; + } + let name = path.rsplit_once('/').map_or(path, |(_, name)| name); + if name.is_empty() || !seen.insert(name.to_string()) { + return None; + } + Some(if name == path { + Suggestion::new(name) + } else { + Suggestion::with_description(name, path) + }) + }) + .collect_unordered_results() +} + +/// Returns a cross-platform generator that lists the names of running processes. +/// +/// Shared by the commands that select processes by name, such as `pkill` and `killall`. +pub fn process_names_generator() -> Generator { + Generator::script( + CommandBuilder::pipe( + CommandBuilder::single_command("ps -A -o comm="), + CommandBuilder::single_command("sort -u"), + ), + process_names, + ) +} + +/// Parses `kill -l` output into signal-name suggestions. +pub fn signal_names(output: &str) -> GeneratorResults { + SIGNAL_NAME + .find_iter(output) + .map(|capture| Suggestion::new(capture.as_str())) + .collect_unordered_results() +} + +/// Returns a generator that lists the signal names accepted by the shell's `kill`. +/// +/// Shared by the commands that take a signal, such as `kill` and `pkill`. +pub fn signal_names_generator() -> Generator { + Generator::script(CommandBuilder::single_command("env kill -l"), signal_names) +} + /// Returns a cross-platform generator that lists local user names. /// /// Uses `getent passwd` on Linux, `dscl` on macOS, and falls back to `/etc/passwd`. @@ -180,3 +238,7 @@ pub fn users_generator() -> Generator { }, ) } + +lazy_static! { + static ref SIGNAL_NAME: Regex = Regex::new(r"(\w+)").unwrap(); +} diff --git a/command-signatures/src/generators/kill.rs b/command-signatures/src/generators/kill.rs index 9445c516..401f905d 100644 --- a/command-signatures/src/generators/kill.rs +++ b/command-signatures/src/generators/kill.rs @@ -1,9 +1,9 @@ -use lazy_static::lazy_static; -use regex::Regex; use warp_completion_metadata::{ CommandBuilder, CommandSignatureGenerators, Generator, GeneratorResultsCollector, Suggestion, }; +use super::common; + pub fn generator() -> CommandSignatureGenerators { CommandSignatureGenerators::new("kill") .add_generator( @@ -28,16 +28,5 @@ pub fn generator() -> CommandSignatureGenerators { }, ), ) - .add_generator( - "signal_name", - Generator::script(CommandBuilder::single_command("env kill -l"), |output| { - RE.find_iter(output) - .map(|capture| Suggestion::new(capture.as_str())) - .collect_unordered_results() - }), - ) -} - -lazy_static! { - static ref RE: Regex = Regex::new(r"(\w+)").unwrap(); + .add_generator("signal_name", common::signal_names_generator()) } diff --git a/command-signatures/src/generators/killall.rs b/command-signatures/src/generators/killall.rs index f5cd06f9..af231ddf 100644 --- a/command-signatures/src/generators/killall.rs +++ b/command-signatures/src/generators/killall.rs @@ -1,34 +1,9 @@ -use warp_completion_metadata::{ - CommandBuilder, CommandSignatureGenerators, Generator, GeneratorResultsCollector, Suggestion, -}; +use warp_completion_metadata::CommandSignatureGenerators; use super::common; pub fn generator() -> CommandSignatureGenerators { CommandSignatureGenerators::new("killall") .add_generator("user_name", common::users_generator()) - .add_generator( - "process_name", - Generator::script( - CommandBuilder::pipe( - CommandBuilder::single_command("ps -A -o comm"), - CommandBuilder::single_command("sort -u"), - ), - |output| { - output - .trim() - .lines() - .filter_map(|path| { - path.rsplit_once('/').and_then(|(_, name)| { - if !name.is_empty() { - Some(Suggestion::with_description(name, path)) - } else { - None - } - }) - }) - .collect_unordered_results() - }, - ), - ) + .add_generator("process_name", common::process_names_generator()) } diff --git a/command-signatures/src/generators/mod.rs b/command-signatures/src/generators/mod.rs index e0222a9b..5f61e727 100644 --- a/command-signatures/src/generators/mod.rs +++ b/command-signatures/src/generators/mod.rs @@ -55,6 +55,9 @@ mod pacman; mod pass; mod phpunit_watcher; mod pip; +mod pkill; +#[cfg(test)] +mod pkill_tests; mod powershell; mod pprof; mod pyenv; @@ -115,6 +118,7 @@ pub fn dynamic_command_signature_data() -> HashMap CommandSignatureGenerators { + CommandSignatureGenerators::new("pkill") + .add_generator("process_name", common::process_names_generator()) + .add_generator("signal_name", common::signal_names_generator()) + .add_generator("user_name", common::users_generator()) +} diff --git a/command-signatures/src/generators/pkill_tests.rs b/command-signatures/src/generators/pkill_tests.rs new file mode 100644 index 00000000..ae2cad08 --- /dev/null +++ b/command-signatures/src/generators/pkill_tests.rs @@ -0,0 +1,100 @@ +use warp_completion_metadata::{ArgumentType, DynamicCompletionData}; + +use super::common::process_names; + +/// Without a `pkill` signature Warp falls back to completing filesystem paths, so the +/// pattern argument must be driven by the process-name generator instead of a template. +#[cfg(feature = "embed-signatures")] +#[test] +fn test_pkill_pattern_argument_completes_process_names() { + let signature = crate::signature_by_name("pkill").expect("pkill signature should be bundled"); + let pattern = signature + .arguments() + .first() + .expect("pkill should accept a positional pattern argument"); + + assert!( + pattern.argument_types.iter().any(|argument_type| matches!( + argument_type, + ArgumentType::Generator(name) if name.0 == "process_name" + )), + "pkill's pattern argument should use the process_name generator, got {:?}", + pattern.argument_types + ); + assert!( + !pattern + .argument_types + .iter() + .any(|argument_type| matches!(argument_type, ArgumentType::Template(_))), + "pkill's pattern argument should not offer file path completions, got {:?}", + pattern.argument_types + ); +} + +#[test] +fn test_pkill_registers_the_generators_its_spec_references() { + let (command, data): (String, DynamicCompletionData) = super::pkill::generator().into(); + let names: Vec<&str> = data + .generators() + .keys() + .map(|name| name.0.as_str()) + .collect(); + + assert_eq!(command, "pkill"); + for expected in ["process_name", "signal_name", "user_name"] { + assert!( + names.contains(&expected), + "pkill should register the {expected} generator, got {names:?}" + ); + } +} + +#[test] +fn test_process_names_uses_basenames_of_macos_style_paths() { + let output = + "/Applications/Warp.app/Contents/MacOS/stable\n/usr/sbin/cfprefsd\n/sbin/launchd\n"; + let results = process_names(output); + let names: Vec<&str> = results + .suggestions + .iter() + .map(|suggestion| suggestion.exact_string.as_str()) + .collect(); + + assert_eq!(names, vec!["stable", "cfprefsd", "launchd"]); + assert_eq!( + results.suggestions[2].description.as_deref(), + Some("/sbin/launchd") + ); +} + +#[test] +fn test_process_names_keeps_linux_style_bare_names() { + let output = "bash\nsystemd\nsshd\n"; + let results = process_names(output); + let names: Vec<&str> = results + .suggestions + .iter() + .map(|suggestion| suggestion.exact_string.as_str()) + .collect(); + + assert_eq!(names, vec!["bash", "systemd", "sshd"]); + assert_eq!(results.suggestions[0].description, None); +} + +#[test] +fn test_process_names_skips_headers_blank_lines_and_duplicate_names() { + let output = "COMMAND\nCOMM\n\n \nbash\n/bin/bash\n/usr/bin/\n"; + let results = process_names(output); + let names: Vec<&str> = results + .suggestions + .iter() + .map(|suggestion| suggestion.exact_string.as_str()) + .collect(); + + assert_eq!(names, vec!["bash"]); +} + +#[test] +fn test_process_names_empty_output() { + assert!(process_names("").suggestions.is_empty()); +}