use clap::{CommandFactory, Parser};
use clap_complete::generate;
use colored::Colorize;
use proc_cli::cli::{Cli, Commands};
use proc_cli::core::{comma_notice, Process};
use proc_cli::error::{ExitCode, ProcError, REASON_INVALID_INPUT};
use proc_cli::ui::out;
use proc_cli::ui::print_note;
use proc_cli::{err_println, out_println};
use std::process;
fn print_orientation() {
out_println!(
"{} {}",
"proc".bold(),
"— semantic process management".bright_black()
);
out_println!();
out_println!(
" {}",
"A target is the same everywhere: :port, PID, or name.".bright_black()
);
out_println!();
for (cmd, what) in [
("proc on :3000", "what's on port 3000"),
("proc in .", "what's running in this directory"),
("proc by node", "processes named node"),
("proc kill :3000", "stop whatever holds the port"),
] {
out_println!(
" {} {}",
format!("{:<20}", cmd).cyan(),
what.bright_black()
);
}
out_println!();
out_println!(
" {} {}",
format!("{:<8}", "Common:").bright_black(),
"on · in · by · ports · info · kill · stop · free · why".white()
);
out_println!();
out_println!(
" {} {} {}",
format!("{:<20}", "proc --help").cyan(),
"all 19 commands".bright_black(),
"· https://proc-cli.com".bright_black()
);
}
fn extract_unknown_arg(msg: &str) -> Option<String> {
let start = msg.find("unexpected argument '")? + "unexpected argument '".len();
let end = msg[start..].find('\'')?;
Some(msg[start..start + end].to_string())
}
fn edit_distance(a: &str, b: &str) -> usize {
let (a, b) = (a.as_bytes(), b.as_bytes());
let (m, n) = (a.len(), b.len());
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
row[0] = i;
}
for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
*val = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
fn suggest_flag(unknown: &str, subcmd_name: &str) -> Option<String> {
let cmd = Cli::command();
let subcmd = cmd.find_subcommand(subcmd_name)?;
let unknown_clean = unknown.trim_start_matches('-');
let mut best: Option<String> = None;
let mut best_dist = usize::MAX;
for arg in subcmd.get_arguments() {
if let Some(long) = arg.get_long() {
let len_diff = (unknown_clean.len() as isize - long.len() as isize).unsigned_abs();
if len_diff <= 2 && long.len() >= 3 {
let dist = edit_distance(unknown_clean, long);
if dist < best_dist {
best_dist = dist;
best = Some(format!("--{}", long));
}
}
if long.contains(unknown_clean) && unknown_clean.len() >= 3 {
return Some(format!("--{}", long));
}
}
}
let threshold = if unknown_clean.len() <= 4 { 2 } else { 3 };
if best_dist <= threshold {
best
} else {
None
}
}
fn main() -> process::ExitCode {
process::ExitCode::from(out::finish(run()))
}
fn run() -> u8 {
if let Some(arg) = first_non_utf8_arg() {
return invalid_argument(&format!(
"argument '{arg}' is not valid UTF-8; proc takes its arguments as UTF-8"
));
}
if let Some(what) = first_empty_arg() {
return invalid_argument(&format!(
"{what} can't be empty: an empty value names no process, port, file or directory"
));
}
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
use clap::error::ErrorKind;
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
out::through_stdout(|| e.print());
return 0;
}
let msg = e.to_string();
let parts = split_clap(&msg);
let mut suggestion = None;
if matches!(
e.kind(),
ErrorKind::UnknownArgument | ErrorKind::InvalidValue
) {
let args = raw_args();
let subcmd = args.iter().find(|a| !a.starts_with('-'));
let unknown = extract_unknown_arg(&msg);
if let (Some(subcmd), Some(unknown)) = (subcmd, unknown) {
suggestion = suggest_flag(&unknown, subcmd);
}
}
let similar = best_first(&parts.similar, e.kind() == ErrorKind::InvalidSubcommand);
if suggestion.is_none() && !similar.is_empty() {
suggestion = Some(similar.join("' or '"));
}
let exit_code = ExitCode::InvalidInput as i32;
let hint = missing_target_hint(&e, &msg);
if wants_json() {
let mut error = parts.one_line();
if let Some(ref suggestion) = suggestion {
error.push_str(&format!("\n Try: did you mean '{}'?", suggestion));
}
if let Some(ref hint) = hint {
error.push_str(&format!("\n {hint}"));
}
print_json_error(&error, exit_code, Some(REASON_INVALID_INPUT), None);
} else {
err_println!("{}", parts.human());
if let Some(suggestion) = suggestion {
err_println!("\n tip: did you mean '{}'?", suggestion);
}
if let Some(hint) = hint {
err_println!("\n tip: {hint}");
}
}
return exit_code as u8;
}
};
if let Some(notice) = Cli::command()
.try_get_matches()
.ok()
.and_then(|matches| comma_notice(&matches))
{
print_note(¬ice);
}
let Some(command) = cli.command else {
print_orientation();
return 0;
};
let result = match command {
Commands::On(cmd) => cmd.execute(),
Commands::For(cmd) => cmd.execute(),
Commands::By(cmd) => cmd.execute(),
Commands::In(cmd) => cmd.execute(),
Commands::List(cmd) => cmd.execute(),
Commands::Info(cmd) => cmd.execute(),
Commands::Ports(cmd) => cmd.execute(),
Commands::Kill(cmd) => cmd.execute(),
Commands::Stop(cmd) => cmd.execute(),
Commands::Tree(cmd) => cmd.execute(),
Commands::Watch(cmd) => cmd.execute(),
Commands::Stuck(cmd) => cmd.execute(),
Commands::Unstick(cmd) => cmd.execute(),
Commands::Freeze(cmd) => cmd.execute(),
Commands::Thaw(cmd) => cmd.execute(),
Commands::Orphans(cmd) => cmd.execute(),
Commands::Why(cmd) => cmd.execute(),
Commands::Free(cmd) => cmd.execute(),
Commands::Wait(cmd) => cmd.execute(),
Commands::Completions { shell } => {
let mut script = Vec::new();
generate(shell, &mut Cli::command(), "proc", &mut script);
out::write_stdout(&script);
Ok(())
}
Commands::Manpage => {
let mut page = Vec::new();
clap_mangen::Man::new(Cli::command())
.render(&mut page)
.map(|()| out::write_stdout(&page))
.map_err(ProcError::from)
}
};
if let Err(e) = result {
let exit_code = ExitCode::from(&e);
if matches!(e, ProcError::Reported { .. }) {
return exit_code as u8;
}
if wants_json() {
let confirmation = match &e {
ProcError::ConfirmationRequired {
processes,
not_found,
..
} => Some((processes.as_slice(), not_found.as_deref())),
_ => None,
};
print_json_error(&e.to_string(), exit_code as i32, e.reason(), confirmation);
} else {
err_println!("{}", e);
}
return exit_code as u8;
}
0
}
fn print_json_error(
error: &str,
exit_code: i32,
reason: Option<&str>,
confirmation: Option<(&[Process], Option<&[String]>)>,
) {
let mut error_json = serde_json::json!({
"action": action_name(),
"success": false,
"error": error,
"exit_code": exit_code
});
if let Some(reason) = reason {
error_json["reason"] = serde_json::json!(reason);
}
if let Some((processes, not_found)) = confirmation {
error_json["needs_confirmation"] = serde_json::json!(true);
error_json["processes"] = serde_json::json!(processes);
if let Some(not_found) = not_found {
error_json["not_found"] = serde_json::json!(not_found);
}
}
out_println!("{}", serde_json::to_string_pretty(&error_json).unwrap());
}
fn action_name() -> String {
let first = raw_args().into_iter().find(|a| !a.starts_with('-'));
match first {
Some(arg) => Cli::command()
.find_subcommand(&arg)
.map(|cmd| cmd.get_name().to_string())
.unwrap_or(arg),
None => "proc".to_string(),
}
}
fn wants_json() -> bool {
raw_args()
.into_iter()
.take_while(|a| a != "--")
.any(|a| is_json_flag(&a))
}
fn raw_args() -> Vec<String> {
std::env::args_os()
.skip(1)
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn invalid_argument(message: &str) -> u8 {
let exit_code = ExitCode::InvalidInput;
if wants_json() {
print_json_error(message, exit_code as i32, Some(REASON_INVALID_INPUT), None);
} else {
err_println!("error: {message}");
}
exit_code as u8
}
fn first_empty_arg() -> Option<String> {
raw_args().into_iter().find_map(|arg| {
if arg.is_empty() {
return Some("an argument".to_string());
}
arg.strip_suffix('=')
.filter(|flag| flag.starts_with('-'))
.map(str::to_string)
})
}
fn first_non_utf8_arg() -> Option<String> {
std::env::args_os()
.skip(1)
.find(|arg| arg.to_str().is_none())
.map(|arg| arg.to_string_lossy().into_owned())
}
struct ClapMessage<'a> {
message: Vec<&'a str>,
similar: Vec<String>,
rest: Vec<&'a str>,
}
fn split_clap(msg: &str) -> ClapMessage<'_> {
let mut parts = ClapMessage {
message: Vec::new(),
similar: Vec::new(),
rest: Vec::new(),
};
let (mut after_blank, mut in_tips) = (false, false);
for line in msg.lines() {
let starts_part = after_blank || in_tips;
after_blank = line.is_empty();
if !parts.rest.is_empty()
|| starts_part
&& (line.starts_with("Usage:") || line.starts_with("For more information"))
{
parts.rest.push(line);
} else if starts_part && line.starts_with(" tip: ") {
in_tips = true;
parts.similar.extend(similar_in(line));
} else if !line.is_empty() && !in_tips {
parts.message.push(line);
}
}
parts
}
fn similar_in(tip: &str) -> Vec<String> {
let list = tip
.split_once(" similar ")
.and_then(|(_, rest)| rest.split_once(" exist"))
.and_then(|(_, rest)| rest.split_once(": "));
let names = list.and_then(|(_, names)| {
names
.split(", ")
.map(|name| {
let name = name.strip_prefix('\'')?.strip_suffix('\'')?;
let plain = !name.is_empty() && !name.contains(['\'', ' ']);
plain.then(|| name.to_string())
})
.collect::<Option<Vec<_>>>()
});
names.unwrap_or_default()
}
fn best_first(similar: &[String], subcommands: bool) -> Vec<String> {
let cli = Cli::command();
let mut names: Vec<String> = Vec::new();
for name in similar.iter().rev() {
let name = match cli.find_subcommand(name) {
Some(sub) if subcommands => sub.get_name().to_string(),
_ => name.clone(),
};
if !names.contains(&name) {
names.push(name);
}
}
names.truncate(3);
names
}
impl ClapMessage<'_> {
fn one_line(&self) -> String {
let lines: Vec<&str> = self
.message
.iter()
.map(|line| line.trim())
.map(|line| line.strip_prefix("error: ").unwrap_or(line))
.collect();
lines.join(" ")
}
fn human(&self) -> String {
let mut text = self.message.join("\n");
if !self.rest.is_empty() {
text.push_str("\n\n");
text.push_str(&self.rest.join("\n"));
}
text
}
}
fn filter_flag_given() -> Option<&'static str> {
for arg in raw_args().iter().take_while(|a| *a != "--") {
if arg == "--by" || arg.starts_with("--by=") {
return Some("--by");
}
if arg == "--in" || arg.starts_with("--in=") {
return Some("--in");
}
let Some(cluster) = arg.strip_prefix('-').filter(|c| !c.starts_with('-')) else {
continue;
};
for c in cluster.chars() {
match c {
'b' => return Some("--by"),
'i' => return Some("--in"),
'd' | 'l' | 'n' | 'p' | 'r' | 's' | 'S' | 't' => break,
_ => {}
}
}
}
None
}
fn missing_target_hint(e: &clap::Error, msg: &str) -> Option<String> {
if e.kind() != clap::error::ErrorKind::MissingRequiredArgument || !msg.contains("<TARGET>") {
return None;
}
let flag = filter_flag_given()?;
Some(format!(
"{} needs at least one target; {flag} filters the targets, it doesn't select them",
action_name()
))
}
fn is_json_flag(arg: &str) -> bool {
if arg == "--json" {
return true;
}
let Some(cluster) = arg.strip_prefix('-') else {
return false;
};
if cluster.starts_with('-') {
return false;
}
for c in cluster.chars() {
match c {
'j' => return true,
'b' | 'd' | 'i' | 'l' | 'n' | 'p' | 'r' | 's' | 'S' | 't' => return false,
_ => {}
}
}
false
}