use std::io::IsTerminal;
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use clap::Parser as ClapParser;
use ishou_tokens::{ShellSignal, ShellSignals, SignalMode};
mod kanshou_state;
use frost_zle::{EditModeKind, InputStatus, ReadLineOutcome, ZleEngine};
fn exec_error_class(e: &frost_exec::ExecError) -> ShellSignal {
use frost_exec::ExecError;
match e {
ExecError::CommandNotFound(_) => ShellSignal::CommandNotFound,
ExecError::Exec(errno) => match *errno as i32 {
2 => ShellSignal::CommandNotFound, 13 => ShellSignal::PermissionDenied, 8 => ShellSignal::ExecFormat, n => ShellSignal::from_errno(n),
},
ExecError::Pipe(_) => ShellSignal::PipeFailed,
ExecError::Fork(_) | ExecError::Wait(_) | ExecError::Redirect(_) => ShellSignal::General,
ExecError::ControlFlow(_) => ShellSignal::General,
}
}
fn shell_mark(class: ShellSignal) -> &'static str {
if std::env::var_os("NO_COLOR").is_some() {
return "";
}
ShellSignals::prescribed().render(class, SignalMode::Emoji)
}
fn shell_warmth() -> &'static str {
if std::env::var_os("NO_COLOR").is_some() {
return "";
}
ShellSignals::prescribed()
.warmth()
.render(SignalMode::Emoji)
}
static PENDING_SIGNALS: AtomicU64 = AtomicU64::new(0);
const TRAPPED_SIGNALS: &[libc::c_int] = &[
libc::SIGUSR1,
libc::SIGUSR2,
libc::SIGTERM,
libc::SIGHUP,
libc::SIGWINCH,
];
const DEFAULT_TERMINATES: &[libc::c_int] =
&[libc::SIGTERM, libc::SIGHUP, libc::SIGUSR1, libc::SIGUSR2];
static TRAPPED_BY_USER: AtomicU64 = AtomicU64::new(0);
fn sync_trapped_signals(env: &frost_exec::ShellEnv) {
let mut mask = 0u64;
for &sig in DEFAULT_TERMINATES {
let name = frost_exec::trap::signal_number_to_name(sig);
if env.functions.contains_key(&format!("__frost_trap_{name}")) {
mask |= 1u64 << sig;
}
}
TRAPPED_BY_USER.store(mask, Ordering::SeqCst);
}
extern "C" fn signal_forwarder(sig: libc::c_int) {
if sig > 0
&& (sig as usize) < 64
&& DEFAULT_TERMINATES.contains(&sig)
&& TRAPPED_BY_USER.load(Ordering::SeqCst) & (1u64 << sig) == 0
{
unsafe { libc::_exit(128 + sig) };
}
if sig > 0 && (sig as usize) < 64 {
PENDING_SIGNALS.fetch_or(1u64 << sig, Ordering::SeqCst);
}
}
fn install_signal_traps() {
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = signal_forwarder as usize;
libc::sigemptyset(&mut action.sa_mask);
action.sa_flags = libc::SA_RESTART;
for &sig in TRAPPED_SIGNALS {
libc::sigaction(sig, &action, std::ptr::null_mut());
}
}
}
fn check_pending_traps(env: &mut frost_exec::ShellEnv) {
let pending = PENDING_SIGNALS.swap(0, Ordering::SeqCst);
if pending == 0 {
return;
}
for sig in 1..64i32 {
if pending & (1u64 << sig) == 0 {
continue;
}
let name = frost_exec::trap::signal_number_to_name(sig);
if name == "UNKNOWN" {
continue;
}
let fn_name = format!("__frost_trap_{name}");
if env.functions.contains_key(&fn_name) {
let _ = run(&fn_name, env);
} else if DEFAULT_TERMINATES.contains(&sig) {
std::process::exit(128 + sig);
}
}
}
#[derive(ClapParser)]
#[command(name = "frost", version, about = "A zsh-compatible shell")]
struct Cli {
#[arg(short = 'c')]
command: Option<String>,
#[arg(long)]
doctor: bool,
#[arg(long)]
mcp: bool,
#[arg(long, requires = "mcp")]
mcp_pid: Option<u32>,
file: Option<String>,
}
const PICKER_SENTINEL_PREFIX: &str = "__frost_picker_";
const PICKER_SENTINEL_SUFFIX: &str = "__";
#[derive(Debug, Clone, Copy)]
enum PickerAction {
Replace,
Append,
CdSubmit,
Submit,
}
enum PickerOutcome {
Nothing,
Splice { text: String, submit: bool },
}
fn run_doctor(_initial_env: &frost_exec::ShellEnv) -> i32 {
use std::fmt::Write as _;
let bold = "\x1b[1m";
let green = "\x1b[32m";
let yellow = "\x1b[33m";
let red = "\x1b[31m";
let reset = "\x1b[0m";
let mut out = String::new();
let mut any_warnings = false;
let _ = writeln!(
out,
"{bold}frost doctor{reset} — v{}",
env!("CARGO_PKG_VERSION")
);
let rc_path = frost_lisp::default_rc_path();
let mut env = frost_exec::ShellEnv::new();
let (summary, load_err) = match frost_lisp::load_rc(&rc_path, &mut env) {
Ok(s) => (s, None),
Err(e) => (frost_lisp::ApplySummary::default(), Some(e.to_string())),
};
let _ = writeln!(out, "\n{bold}rc{reset}");
let _ = writeln!(out, " path: {}", rc_path.display());
if let Some(e) = &load_err {
any_warnings = true;
let _ = writeln!(out, " {red}load failed:{reset} {e}");
} else {
let _ = writeln!(
out,
" {green}loaded{reset} aliases={} env={} hooks={} binds={} pickers={} \
subcmds={} flags={} positionals={} marks={} integrations={} abbreviations={}",
summary.aliases,
summary.env_vars,
summary.hooks,
summary.binds,
summary.pickers.len(),
summary.subcmds.len(),
summary.flags.len(),
summary.positionals.len(),
summary.marks.len(),
summary.integrations,
summary.abbreviations.len(),
);
}
let _ = writeln!(out, "\n{bold}bundled tools{reset}");
let canonical: &[&str] = &[
"sk",
"skim-history",
"skim-files",
"skim-cd",
"skim-content",
"zoxide",
"atuin",
"starship",
"direnv",
"fd",
"rg",
"bat",
"delta",
"eza",
"jq",
"git",
"tig",
"blx-ls",
"kubectl",
"kubecolor",
"helm",
"flux",
"k9s",
"stern",
"aws",
"gcloud",
"az",
];
let mut missing: Vec<&str> = Vec::new();
for tool in canonical {
if path_probe(tool).is_none() {
missing.push(*tool);
}
}
if missing.is_empty() {
let _ = writeln!(
out,
" {green}all {} bundled tools on PATH{reset}",
canonical.len()
);
} else {
any_warnings = true;
let _ = writeln!(
out,
" {yellow}{}/{} on PATH; missing:{reset} {}",
canonical.len() - missing.len(),
canonical.len(),
missing.join(", ")
);
}
if !summary.warnings.is_empty() {
any_warnings = true;
let _ = writeln!(out, "\n{bold}apply warnings{reset}");
for w in &summary.warnings {
let _ = writeln!(out, " {yellow}!{reset} {w}");
}
}
if !summary.marks.is_empty() {
let _ = writeln!(out, "\n{bold}marks{reset}");
let mut keys: Vec<&String> = summary.marks.keys().collect();
keys.sort();
for k in keys {
let path = &summary.marks[k];
let exists = std::path::Path::new(path).exists();
let tag = if exists {
format!("{green}✓{reset}")
} else {
any_warnings = true;
format!("{yellow}?{reset}")
};
let _ = writeln!(out, " {tag} {k:<12} → {path}");
}
}
if !summary.pickers.is_empty() {
let _ = writeln!(out, "\n{bold}pickers{reset}");
for p in &summary.pickers {
let have = path_probe(&p.binary).is_some();
let tag = if have {
format!("{green}✓{reset}")
} else {
any_warnings = true;
format!("{yellow}?{reset}")
};
let _ = writeln!(out, " {tag} {:<4} → {:<20} ({})", p.key, p.binary, p.name);
}
}
let _ = writeln!(out, "\n{bold}widgets{reset}");
for name in [
"edit-line",
"clear-screen",
"copy-to-clipboard",
"paste-from-clipboard",
"kill-buffer",
"insert-last-arg",
"toggle-sudo",
] {
let _ = writeln!(out, " {green}●{reset} __frost_widget_{name}__");
}
let _ = writeln!(out);
if any_warnings {
let _ = writeln!(
out,
"{yellow}warnings present — frost runs but some features may not work{reset}"
);
} else {
let _ = writeln!(out, "{green}all green{reset}");
}
print!("{out}");
if any_warnings { 1 } else { 0 }
}
fn path_probe(name: &str) -> Option<std::path::PathBuf> {
let path = std::env::var("PATH").ok()?;
for dir in path.split(':').filter(|p| !p.is_empty()) {
let candidate = std::path::Path::new(dir).join(name);
if let Ok(meta) = std::fs::metadata(&candidate) {
if !meta.is_file() {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 == 0 {
continue;
}
}
return Some(candidate);
}
}
None
}
fn dispatch_widget(
sentinel: &str,
zle: &mut frost_zle::ZleEngine,
history: &frost_history::History,
) {
let Some(name) = sentinel
.strip_prefix("__frost_widget_")
.and_then(|s| s.strip_suffix("__"))
else {
return;
};
match name {
"edit_line" | "edit-line" => widget_edit_line(zle),
"clear" | "clear-screen" => {
print!("\x1b[2J\x1b[H");
let _ = std::io::Write::flush(&mut std::io::stdout());
}
"copy-to-clipboard" | "copy_to_clipboard" | "copy" => widget_copy_to_clipboard(zle),
"paste-from-clipboard" | "paste_from_clipboard" | "paste" => {
widget_paste_from_clipboard(zle)
}
"kill-buffer" | "kill_buffer" | "clear-buffer" => {
zle.inject_prefill("");
}
"insert-last-arg" | "insert_last_arg" | "last-arg" => {
widget_insert_last_arg(zle, history);
}
"toggle-sudo" | "toggle_sudo" | "sudo-toggle" => widget_toggle_sudo(zle),
_ => {
eprintln!("frost: unknown widget: {name}");
}
}
}
fn widget_toggle_sudo(zle: &mut frost_zle::ZleEngine) {
let buffer = zle.current_buffer_contents().unwrap_or_default();
let new_buffer = if let Some(stripped) = buffer.strip_prefix("sudo ") {
stripped.to_string()
} else {
format!("sudo {buffer}")
};
zle.inject_prefill(&new_buffer);
}
fn widget_insert_last_arg(zle: &mut frost_zle::ZleEngine, history: &frost_history::History) {
let Some(prev) = history.previous() else {
return;
};
let last = last_argument(prev);
if last.is_empty() {
return;
}
let existing = zle.current_buffer_contents().unwrap_or_default();
let sep = if existing.is_empty() || existing.ends_with(char::is_whitespace) {
""
} else {
" "
};
zle.inject_prefill(&format!("{existing}{sep}{last}"));
}
fn last_argument(cmd: &str) -> String {
let trimmed = cmd.trim_end();
if trimmed.is_empty() {
return String::new();
}
let bytes = trimmed.as_bytes();
let last = bytes[bytes.len() - 1];
if last == b'"' || last == b'\'' {
let quote = last;
let mut i = bytes.len() - 2;
while i > 0 {
if bytes[i] == quote {
return trimmed[i..].to_string();
}
if i == 0 {
break;
}
i -= 1;
}
if bytes[0] == quote {
return trimmed.to_string();
}
}
let mut i = trimmed.len();
while i > 0 {
let c = bytes[i - 1];
if c == b' ' || c == b'\t' {
return trimmed[i..].to_string();
}
i -= 1;
}
trimmed.to_string()
}
fn widget_copy_to_clipboard(zle: &frost_zle::ZleEngine) {
use std::io::Write;
use std::process::{Command, Stdio};
let buffer = zle.current_buffer_contents().unwrap_or_default();
let candidates: &[(&str, &[&str])] = &[
("pbcopy", &[]), ("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"]), ];
for (bin, args) in candidates {
let Ok(mut child) = Command::new(bin)
.args(*args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
continue;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(buffer.as_bytes());
}
let _ = child.wait();
return;
}
eprintln!(
"frost: copy-to-clipboard: no clipboard tool found (tried pbcopy / wl-copy / xclip / xsel)"
);
}
fn widget_paste_from_clipboard(zle: &mut frost_zle::ZleEngine) {
use std::process::Command;
let candidates: &[(&str, &[&str])] = &[
("pbpaste", &[]),
("wl-paste", &[]),
("xclip", &["-selection", "clipboard", "-o"]),
("xsel", &["--clipboard", "--output"]),
];
for (bin, args) in candidates {
let Ok(output) = Command::new(bin).args(*args).output() else {
continue;
};
if !output.status.success() {
continue;
}
let Ok(text) = String::from_utf8(output.stdout) else {
continue;
};
let text = text.trim_end_matches('\n');
let existing = zle.current_buffer_contents().unwrap_or_default();
let combined = if existing.is_empty() {
text.to_string()
} else {
format!("{existing}{text}")
};
zle.inject_prefill(&combined);
return;
}
eprintln!(
"frost: paste-from-clipboard: no clipboard tool found (tried pbpaste / wl-paste / xclip / xsel)"
);
}
fn widget_edit_line(zle: &mut frost_zle::ZleEngine) {
use std::io::Write;
let buffer = zle.current_buffer_contents().unwrap_or_default();
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "vi".to_string());
let path = std::env::temp_dir().join(format!("frost-edit-line-{}.sh", std::process::id()));
{
let Ok(mut f) = std::fs::File::create(&path) else {
eprintln!("frost: edit-line: cannot create {}", path.display());
return;
};
let _ = f.write_all(buffer.as_bytes());
}
let argv: Vec<String> = editor.split_whitespace().map(String::from).collect();
let Some((bin, rest)) = argv.split_first() else {
eprintln!("frost: edit-line: EDITOR is empty");
let _ = std::fs::remove_file(&path);
return;
};
let status = std::process::Command::new(bin)
.args(rest)
.arg(&path)
.status();
if !status.map(|s| s.success()).unwrap_or(false) {
let _ = std::fs::remove_file(&path);
return;
}
let edited = std::fs::read_to_string(&path).unwrap_or_default();
let _ = std::fs::remove_file(&path);
let trimmed = edited.trim_end_matches('\n').to_string();
zle.inject_prefill(&trimmed);
}
fn read_one_chord() -> Option<String> {
use crossterm::event::{self, Event, KeyEvent};
use crossterm::terminal;
let _ = terminal::enable_raw_mode();
let result = loop {
match event::read() {
Ok(Event::Key(KeyEvent {
code, modifiers, ..
})) => break format_chord(code, modifiers),
Ok(_) => continue, Err(_) => break None,
}
};
let _ = terminal::disable_raw_mode();
result
}
fn format_chord(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<String> {
use crossterm::event::{KeyCode, KeyModifiers};
let key_name = match code {
KeyCode::Char(' ') => "space".into(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Tab => "tab".into(),
KeyCode::Enter => "enter".into(),
KeyCode::Esc => "esc".into(),
KeyCode::Up => "up".into(),
KeyCode::Down => "down".into(),
KeyCode::Left => "left".into(),
KeyCode::Right => "right".into(),
KeyCode::Home => "home".into(),
KeyCode::End => "end".into(),
KeyCode::PageUp => "pageup".into(),
KeyCode::PageDown => "pagedown".into(),
KeyCode::Backspace => "backspace".into(),
KeyCode::Delete => "delete".into(),
_ => return None,
};
let mut parts: Vec<&str> = Vec::new();
if modifiers.contains(KeyModifiers::CONTROL) {
parts.push("C");
}
if modifiers.contains(KeyModifiers::ALT) {
parts.push("M");
}
if modifiers.contains(KeyModifiers::SHIFT) && key_name.chars().count() > 1 {
parts.push("S");
}
if parts.is_empty() {
Some(key_name)
} else {
Some(format!("{}-{}", parts.join("-"), key_name))
}
}
fn run_skim_tab_picker(
bin: &str,
query: Option<&str>,
extra_env: &[(&str, String)],
) -> Option<String> {
let mut takeover = frost_exec::tty_takeover::TtyTakeover::new(bin);
if let Some(q) = query {
let q = q.trim();
if !q.is_empty() {
takeover = takeover.arg("--query").arg(q);
}
}
for (k, v) in extra_env {
takeover = takeover.env(k, v);
}
takeover.spawn_and_capture().ok().flatten()
}
impl PickerAction {
fn from_str(s: &str) -> Option<Self> {
match s {
"replace" => Some(Self::Replace),
"append" => Some(Self::Append),
"cd-submit" => Some(Self::CdSubmit),
"submit" => Some(Self::Submit),
_ => None,
}
}
}
fn dispatch_picker_sentinel(
sentinel: &str,
query: Option<&str>,
history_path: &std::path::Path,
specs: &[frost_lisp::PickerSpec],
) -> Option<(PickerOutcome, PickerAction)> {
let name = sentinel
.strip_prefix(PICKER_SENTINEL_PREFIX)?
.strip_suffix(PICKER_SENTINEL_SUFFIX)?;
let spec = specs.iter().find(|s| s.name == name)?;
let action = PickerAction::from_str(&spec.action)?;
let extra_env: Vec<(&str, String)> = if spec.binary == "skim-history" {
vec![("HISTFILE", history_path.to_string_lossy().into_owned())]
} else {
vec![]
};
let Some(sel) = run_skim_tab_picker(&spec.binary, query, &extra_env) else {
return Some((PickerOutcome::Nothing, action));
};
let outcome = match action {
PickerAction::Replace | PickerAction::Append => PickerOutcome::Splice {
text: sel,
submit: false,
},
PickerAction::CdSubmit => PickerOutcome::Splice {
text: format!("cd {sel}"),
submit: true,
},
PickerAction::Submit => PickerOutcome::Splice {
text: sel,
submit: true,
},
};
Some((outcome, action))
}
enum RunOutcome {
Completed(i32),
Exit(i32),
}
fn run_script(input: &str, env: &mut frost_exec::ShellEnv) -> RunOutcome {
let tokens = tokenize(input);
let mut parser = frost_parser::Parser::new(&tokens);
let program = parser.parse();
if !program.syntax_errors.is_empty() {
for e in &program.syntax_errors {
eprintln!("frost: syntax error: {e}");
}
return RunOutcome::Completed(2);
}
run(input, env)
}
fn run(input: &str, env: &mut frost_exec::ShellEnv) -> RunOutcome {
let tokens = tokenize(input);
let mut parser = frost_parser::Parser::new(&tokens);
let program = parser.parse();
let outcome = {
let mut executor = frost_exec::Executor::new(env);
executor.execute_program(&program)
};
let err = match outcome {
Ok(status) => return RunOutcome::Completed(status),
Err(frost_exec::ExecError::ControlFlow(frost_exec::ControlFlow::Exit(code))) => {
return RunOutcome::Exit(code);
}
Err(e) => e,
};
match suggest::MissingCommand::from_error(err) {
Ok(missing) => {
let mark = shell_mark(ShellSignal::CommandNotFound);
let name = missing.name();
if mark.is_empty() {
eprintln!("frost: command not found: {name}");
} else {
eprintln!("{mark} frost: command not found: {name}");
}
let suggestions = missing.suggestions(env);
if !suggestions.is_empty() {
let warmth = shell_warmth();
let joined = suggestions.join(", ");
if warmth.is_empty() {
eprintln!("frost: did you mean {joined}?");
} else {
eprintln!("frost: did you mean {joined}? {warmth}");
}
}
RunOutcome::Completed(127)
}
Err(e) => {
let mark = shell_mark(exec_error_class(&e));
if mark.is_empty() {
eprintln!("frost: {e}");
} else {
eprintln!("{mark} frost: {e}");
}
RunOutcome::Completed(1)
}
}
}
mod suggest {
use frost_exec::{ExecError, ShellEnv};
pub(super) struct MissingCommand {
name: String,
}
impl MissingCommand {
pub(super) fn from_error(err: ExecError) -> Result<Self, ExecError> {
match err {
ExecError::CommandNotFound(name) => Ok(Self { name }),
other => Err(other),
}
}
pub(super) fn name(&self) -> &str {
&self.name
}
pub(super) fn suggestions(&self, env: &ShellEnv) -> Vec<String> {
did_you_mean(&self.name, &corpus(env))
}
}
fn corpus(env: &ShellEnv) -> Vec<String> {
let mut names: Vec<String> = env
.aliases
.keys()
.cloned()
.chain(env.functions.keys().cloned())
.chain(
frost_complete::default_builtin_list()
.iter()
.map(|s| (*s).to_string()),
)
.collect();
if let Some(path) = env.get_var("PATH") {
names.extend(frost_complete::path_command_names(path));
}
names
}
fn did_you_mean(typed: &str, names: &[String]) -> Vec<String> {
let typed_chars: Vec<char> = typed.chars().collect();
let typed_set: std::collections::HashSet<char> = typed_chars.iter().copied().collect();
let mut scored: Vec<(usize, usize, usize, &String)> = names
.iter()
.map(|n| {
let d = levenshtein(typed, n);
let shared = n.chars().filter(|c| typed_set.contains(c)).count();
let prefix = common_prefix_len(&typed_chars, n);
(d, shared, prefix, n)
})
.filter(|(d, _, _, _)| *d <= 2 && *d > 0)
.collect();
scored.sort_by(|a, b| {
a.0.cmp(&b.0) .then(b.1.cmp(&a.1)) .then(b.2.cmp(&a.2)) .then(a.3.cmp(b.3)) });
scored
.into_iter()
.take(3)
.map(|(_, _, _, n)| n.clone())
.collect()
}
fn common_prefix_len(a: &[char], b: &str) -> usize {
a.iter().zip(b.chars()).take_while(|(x, y)| *x == y).count()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::{MissingCommand, did_you_mean, levenshtein};
use frost_exec::ExecError;
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn levenshtein_known_cases() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("git", "gti"), 2); assert_eq!(levenshtein("ls", "ls"), 0);
assert_eq!(levenshtein("l", "ls"), 1);
assert_eq!(levenshtein("helloo", "hello"), 1);
}
#[test]
fn did_you_mean_surfaces_close_matches() {
let names = names(&["git", "ls", "echo", "cd", "cat"]);
let s = did_you_mean("gti", &names);
assert!(s.contains(&"git".to_string()), "{s:?}");
let s2 = did_you_mean("l", &names);
assert!(s2.contains(&"ls".to_string()), "{s2:?}");
}
#[test]
fn did_you_mean_ignores_unrelated() {
let names = names(&["git", "ls", "echo"]);
let s = did_you_mean("completely-unrelated", &names);
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn did_you_mean_prefers_prefix_matches() {
let names = names(&["tr", "fi", "git", "gem"]);
let s = did_you_mean("gti", &names);
assert_eq!(s[0], "git", "top suggestion should be git, got {s:?}");
}
#[test]
fn did_you_mean_caps_at_three_suggestions() {
let names = names(&["gxt", "git", "gxxt", "gjt", "gzt", "gyt"]);
let s = did_you_mean("got", &names);
assert!(s.len() <= 3, "got {} suggestions: {s:?}", s.len());
}
#[test]
fn witness_is_minted_only_by_command_not_found() {
let missing = MissingCommand::from_error(ExecError::CommandNotFound("bxl".into()))
.expect("CommandNotFound must yield the witness");
assert_eq!(missing.name(), "bxl");
let other = ExecError::ControlFlow(frost_exec::ControlFlow::Exit(3));
let back = MissingCommand::from_error(other);
assert!(
back.is_err(),
"a non-CommandNotFound error must NOT yield a suggestion witness"
);
}
}
}
fn tokenize(input: &str) -> Vec<frost_lexer::Token> {
frost_lexer::tokenize_str(input)
}
fn is_complete(src: &str) -> bool {
if src
.trim_end_matches(|c: char| c == ' ' || c == '\t')
.ends_with('\\')
{
return false;
}
let bytes = src.as_bytes();
let mut i = 0;
let mut paren = 0i32;
let mut brace = 0i32;
let mut bracket = 0i32;
let mut in_single = false;
let mut in_double = false;
let mut kw: Vec<&'static str> = Vec::new();
while i < bytes.len() {
let c = bytes[i];
if in_single {
if c == b'\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if c == b'"' {
in_double = false;
}
i += 1;
continue;
}
match c {
b'\'' => {
in_single = true;
i += 1;
}
b'"' => {
in_double = true;
i += 1;
}
b'\\' if i + 1 < bytes.len() => {
i += 2;
}
b'(' => {
paren += 1;
i += 1;
}
b')' => {
paren -= 1;
i += 1;
}
b'[' => {
bracket += 1;
i += 1;
}
b']' => {
bracket -= 1;
i += 1;
}
b'{' => {
brace += 1;
i += 1;
}
b'}' => {
brace -= 1;
i += 1;
}
b'#' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let is_command_start = start == 0
|| matches!(
bytes[start - 1],
b' ' | b'\t' | b'\n' | b';' | b'|' | b'&' | b'(' | b'{'
);
if !is_command_start {
continue;
}
let word = &src[start..i];
match word {
"if" => kw.push("fi"),
"while" | "until" | "for" | "select" | "repeat" => kw.push("done"),
"case" => kw.push("esac"),
"do" | "then" | "else" | "elif" | "in" => {}
"fi" if kw.last().copied() == Some("fi") => {
kw.pop();
}
"done" if kw.last().copied() == Some("done") => {
kw.pop();
}
"esac" if kw.last().copied() == Some("esac") => {
kw.pop();
}
_ => {}
}
}
_ => {
i += 1;
}
}
}
!in_single && !in_double && paren <= 0 && brace <= 0 && bracket <= 0 && kw.is_empty()
}
fn resolve_history_capacity(env: &frost_exec::ShellEnv) -> usize {
match env.get_var("HISTSIZE") {
Some(raw) => match raw.trim().parse::<usize>() {
Ok(n) if n > 0 => n,
_ => {
eprintln!(
"frost: warning: HISTSIZE={raw:?} is not a positive integer; \
using {} entries",
frost_config::DEFAULT_HISTORY_SIZE
);
frost_config::DEFAULT_HISTORY_SIZE
}
},
None => frost_config::DEFAULT_HISTORY_SIZE,
}
}
fn interactive(
env: &mut frost_exec::ShellEnv,
rc_completions: std::collections::HashMap<String, Vec<String>>,
rc_binds: Vec<(String, String)>,
rc_descriptions: std::collections::HashMap<String, String>,
rc_payloads: std::collections::HashMap<String, String>,
rc_pickers: Vec<frost_lisp::PickerSpec>,
rc_subcmds: Vec<frost_lisp::SubcmdSpec>,
rc_flags: Vec<frost_lisp::FlagSpec>,
rc_positionals: Vec<frost_lisp::PositSpec>,
rc_abbreviations: std::collections::HashMap<String, String>,
rc_theme: frost_lisp::ThemeSpec,
rc_multi_key: Vec<(String, String, String)>,
) {
unsafe {
libc::signal(libc::SIGINT, libc::SIG_IGN);
}
install_signal_traps();
sync_trapped_signals(env);
let history_path = frost_zle::default_history_path();
let history_capacity = resolve_history_capacity(env);
let zle_base = match ZleEngine::new(&history_path, history_capacity) {
Ok(z) => z,
Err(e) => {
eprintln!("frost: ZLE init failed ({e}); falling back to in-memory history");
ZleEngine::in_memory()
}
};
let completer = Box::new(
frost_complete::FrostCompleter::with_default_builtins()
.with_arg_completions(rc_completions.clone())
.with_descriptions(rc_descriptions)
.with_defcompletion_payloads(rc_payloads)
.with_rich_completions(&rc_subcmds, &rc_flags, &rc_positionals)
.with_dir_oracle(Box::new(|word| frost_exec::frecent_dirs(word, 8))),
);
let known_commands: Vec<String> = frost_complete::default_builtin_list()
.iter()
.map(|s| s.to_string())
.chain(env.aliases.keys().cloned())
.chain(env.functions.keys().cloned())
.chain(rc_completions.keys().cloned())
.collect();
let palette = frost_zle::Palette::from_hex_slots(frost_zle::PaletteSlots {
command: rc_theme.command.as_deref(),
unknown_command: rc_theme.unknown_command.as_deref(),
reserved: rc_theme.reserved.as_deref(),
string: rc_theme.string.as_deref(),
variable: rc_theme.variable.as_deref(),
operator: rc_theme.operator.as_deref(),
comment: rc_theme.comment.as_deref(),
glob: rc_theme.glob.as_deref(),
number: rc_theme.number.as_deref(),
tilde: None, broken_path: rc_theme.broken_path.as_deref(),
});
let highlighter = Box::new(
frost_zle::FrostHighlighter::with_known(known_commands)
.with_palette(palette)
.with_path_checks(true),
);
let mut zle = zle_base
.with_completer(completer)
.with_highlighter(highlighter)
.with_history_hints(rc_theme.hint.as_deref())
.with_bindings(rc_binds);
let mut history = frost_history::History::from_file_readonly(&history_path)
.unwrap_or_else(|_| frost_history::History::new());
let mut cpr_retries: u32 = 0;
const MAX_CPR_RETRIES: u32 = 200;
loop {
check_pending_traps(env);
sync_trapped_signals(env);
run_hook("__frost_hook_precmd", env);
let ps1_raw = env
.get_var("PS1")
.map(|s| s.to_string())
.unwrap_or_else(|| "frost> ".to_string());
let ps2_raw = env
.get_var("PS2")
.map(|s| s.to_string())
.unwrap_or_else(|| "> ".to_string());
let rps1_raw = env
.get_var("RPS1")
.map(|s| s.to_string())
.unwrap_or_default();
let pe = {
let mut pe = frost_prompt::PromptEnv::snapshot(env.exit_status);
for name in ["USER", "HOME", "PWD", "HOST", "HOSTNAME", "SHELL", "STATUS"] {
if let Some(v) = env.get_var(name) {
pe.extra_vars.insert(name.to_string(), v.to_string());
}
}
pe
};
let prompt_subst = env.is_option_set(frost_options::ShellOption::PromptSubst);
let ps1 = frost_prompt::render(&ps1_raw, &pe, prompt_subst);
let ps2 = frost_prompt::render(&ps2_raw, &pe, prompt_subst);
let rps1 = if rps1_raw.is_empty() {
String::new()
} else {
frost_prompt::render(&rps1_raw, &pe, prompt_subst)
};
zle.set_prompt_with_rps1(ps1, ps2, rps1);
let wanted = if env.is_option_set(frost_options::ShellOption::Vi) {
EditModeKind::Vi
} else {
EditModeKind::Emacs
};
zle.set_edit_mode(wanted);
let outcome = zle.read_line(|src| {
if is_complete(src) {
InputStatus::Complete
} else {
InputStatus::Incomplete
}
});
match outcome {
Ok(ReadLineOutcome::Input(line)) => {
cpr_retries = 0;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(first_chord) = trimmed
.strip_prefix("__frost_chord_prefix_")
.and_then(|s| s.strip_suffix("__"))
{
if let Some(second_chord) = read_one_chord() {
let found = rc_multi_key
.iter()
.find(|(p, r, _)| p == first_chord && *r == second_chord);
if let Some((_, _, stored)) = found {
let stored = stored.clone();
if frost_lisp::is_widget_action(&stored) {
dispatch_widget(&stored, &mut zle, &history);
} else {
match run(&stored, env) {
RunOutcome::Completed(_) => {}
RunOutcome::Exit(code) => {
run_exit_trap(env);
std::process::exit(code);
}
}
}
}
}
continue;
}
if frost_lisp::is_widget_action(trimmed) {
dispatch_widget(trimmed, &mut zle, &history);
continue;
}
let query = zle.current_buffer_contents();
if let Some((outcome, action)) =
dispatch_picker_sentinel(trimmed, query.as_deref(), &history_path, &rc_pickers)
{
let PickerOutcome::Splice { text, submit } = outcome else {
continue;
};
match action {
PickerAction::Replace => {
zle.inject_prefill(&text);
}
PickerAction::Append => {
let existing = query.as_deref().unwrap_or("");
let sep = if existing.is_empty() || existing.ends_with(' ') {
""
} else {
" "
};
zle.inject_prefill(&format!("{existing}{sep}{text}"));
}
PickerAction::CdSubmit | PickerAction::Submit => {
if submit {
let _ = history.push(text.clone());
frost_exec::record_command(&text);
run_hook("__frost_hook_preexec", env);
match run(&text, env) {
RunOutcome::Completed(_) => {}
RunOutcome::Exit(code) => {
run_exit_trap(env);
std::process::exit(code);
}
}
} else {
zle.inject_prefill(&text);
}
}
}
continue;
}
let (abbrev_expanded, abbrev_changed) =
frost_lisp::expand_abbreviation(&line, &rc_abbreviations);
let line = if abbrev_changed {
println!("{abbrev_expanded}");
abbrev_expanded
} else {
line
};
let (to_run, expansion_failed) = match frost_history::expand(&line, &history) {
Ok((expanded, changed)) => {
if changed {
println!("{expanded}");
}
(expanded, false)
}
Err(e) => {
eprintln!("frost: {e}");
(line.clone(), true)
}
};
if expansion_failed {
continue;
}
let _ = history.push(to_run.clone());
frost_exec::record_command(&to_run);
zle.sync_history();
run_hook("__frost_hook_preexec", env);
match run(&to_run, env) {
RunOutcome::Completed(_) => {}
RunOutcome::Exit(code) => {
run_exit_trap(env);
std::process::exit(code);
}
}
}
Ok(ReadLineOutcome::Interrupted) => {
cpr_retries = 0;
continue;
}
Ok(ReadLineOutcome::Eof) => break,
Err(e) => {
if e.to_string().contains("cursor position") && cpr_retries < MAX_CPR_RETRIES {
cpr_retries += 1;
std::thread::sleep(std::time::Duration::from_millis(25));
continue;
}
eprintln!("frost: read error: {e}");
break;
}
}
}
run_exit_trap(env);
}
#[allow(dead_code)]
fn discover_latest_frost_socket() -> Option<(u32, std::path::PathBuf)> {
let dir = std::env::var_os("HOME").map(|h| {
let mut p = std::path::PathBuf::from(h);
p.push(".local/state/frost");
p
})?;
let entries = std::fs::read_dir(&dir).ok()?;
let mut best: Option<(std::time::SystemTime, u32, std::path::PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name()?.to_str()?.to_string();
let pid_str = name.strip_prefix("mcp-")?.strip_suffix(".sock")?;
let pid: u32 = pid_str.parse().ok()?;
let mtime = entry.metadata().ok()?.modified().ok()?;
match &best {
None => best = Some((mtime, pid, path)),
Some((t, _, _)) if mtime > *t => best = Some((mtime, pid, path)),
_ => {}
}
}
best.map(|(_, pid, path)| (pid, path))
}
#[allow(dead_code)]
async fn run_mcp_bridge(target_pid: Option<u32>) -> i32 {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let socket_path: std::path::PathBuf = match target_pid {
Some(pid) => match frost_mcp::default_socket_path(pid) {
Some(p) => {
if !p.exists() {
eprintln!(
"frost --mcp: no socket for pid {pid} at {} (is that frost running?)",
p.display()
);
return 2;
}
p
}
None => {
eprintln!("frost --mcp: $HOME not set; cannot resolve socket path");
return 1;
}
},
None => match discover_latest_frost_socket() {
Some((pid, p)) => {
eprintln!(
"frost --mcp: bridging to latest frost pid {pid} ({})",
p.display()
);
p
}
None => {
eprintln!("frost --mcp: no running frost shells found under ~/.local/state/frost/");
return 1;
}
},
};
let stream = match tokio::net::UnixStream::connect(&socket_path).await {
Ok(s) => s,
Err(e) => {
eprintln!(
"frost --mcp: failed to connect to {}: {e}",
socket_path.display()
);
return 1;
}
};
let (mut sock_rd, mut sock_wr) = stream.into_split();
let mut stdin = tokio::io::stdin();
let mut stdout = tokio::io::stdout();
let stdin_to_sock = async move {
let mut buf = vec![0u8; 8192];
loop {
match stdin.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if sock_wr.write_all(&buf[..n]).await.is_err() {
break;
}
}
Err(_) => break,
}
}
let _ = sock_wr.shutdown().await;
};
let sock_to_stdout = async move {
let mut buf = vec![0u8; 8192];
loop {
match sock_rd.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).await.is_err() {
break;
}
let _ = stdout.flush().await;
}
Err(_) => break,
}
}
};
tokio::select! {
_ = stdin_to_sock => {},
_ = sock_to_stdout => {},
}
0
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
if cli.mcp {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("frost-mcp bridge tokio runtime");
let code = rt.block_on(async {
if let Err(e) = frost_mcp::serve_stdio().await {
eprintln!("frost --mcp: stdio MCP server error: {e}");
1
} else {
0
}
});
process::exit(code);
}
let boot_posture = frost::boot_posture::detect();
tracing::info!(
in_nix_shell = boot_posture.in_nix_shell,
direnv_active = boot_posture.direnv_active,
via_ssh = boot_posture.via_ssh,
interactive = boot_posture.interactive,
login = boot_posture.login,
"frost boot posture detected",
);
let mut env = frost_exec::ShellEnv::new();
let kanshou_shell_state = std::sync::Arc::new(kanshou_state::FrostShellState::new());
if let Some(path) =
kanshou::Server::spawn_sidecar("frost", std::sync::Arc::clone(&kanshou_shell_state))
{
tracing::info!(socket = %path.display(), "kanshou introspection live");
}
let rc_path = frost_lisp::default_rc_path();
kanshou_shell_state
.rc_path
.write()
.replace(rc_path.display().to_string());
let mut mcp_state = frost_mcp::FrostState::boot(std::process::id());
let (
rc_completions,
rc_binds,
rc_descriptions,
rc_payloads,
rc_pickers,
rc_subcmds,
rc_flags,
rc_positionals,
rc_abbreviations,
rc_theme,
rc_multi_key,
) = match frost_lisp::load_rc(&rc_path, &mut env) {
Ok(summary) => {
if summary != frost_lisp::ApplySummary::default() {
tracing::debug!(
?summary,
rc = %rc_path.display(),
"loaded frost-lisp rc file"
);
}
for w in &summary.warnings {
eprintln!("frost: warning: {w}");
}
mcp_state.rc_path = Some(rc_path.clone());
mcp_state.rc_loaded = true;
kanshou_shell_state.rc_loaded.store(true, Ordering::SeqCst);
if let Some(hist) = env.get_var("HISTFILE") {
kanshou_shell_state
.history_path
.write()
.replace(hist.to_string());
}
mcp_state.bindings = summary.bind_map.clone();
mcp_state.pickers = summary
.pickers
.iter()
.map(|p| frost_mcp::PickerInfo {
name: p.name.clone(),
key: p.key.clone(),
binary: p.binary.clone(),
action: p.action.clone(),
})
.collect();
mcp_state.widgets = summary.bound_widgets();
mcp_state.history_file = env.get_var("HISTFILE").map(std::path::PathBuf::from);
mcp_state.alias_count = summary.aliases;
mcp_state.subcmd_count = summary.subcmds.len();
mcp_state.flag_count = summary.flags.len();
mcp_state.positional_count = summary.positionals.len();
mcp_state.abbreviation_count = summary.abbreviations.len();
(
summary.completion_map,
summary.bind_map,
summary.completion_descriptions,
summary.completion_payloads,
summary.pickers,
summary.subcmds,
summary.flags,
summary.positionals,
summary.abbreviations,
summary.theme,
summary.multi_key_bindings,
)
}
Err(e) => {
eprintln!("frost: warning: failed to load {}: {e}", rc_path.display());
mcp_state.rc_path = Some(rc_path.clone());
mcp_state.rc_loaded = false;
mcp_state.rc_error = Some(e.to_string());
(
std::collections::HashMap::new(),
Vec::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
std::collections::HashMap::new(),
frost_lisp::borealis_night(),
Vec::new(),
)
}
};
if cli.doctor {
let code = run_doctor(&env);
run_exit_trap(&mut env);
process::exit(code);
}
let code = if let Some(cmd) = &cli.command {
let (cmd_expanded, changed) = frost_lisp::expand_abbreviation(cmd, &rc_abbreviations);
if changed {
println!("{cmd_expanded}");
}
unwrap_outcome(run_script(&cmd_expanded, &mut env))
} else if let Some(path) = &cli.file {
match std::fs::read_to_string(path) {
Ok(source) => unwrap_outcome(run_script(&source, &mut env)),
Err(e) => {
eprintln!("frost: {path}: {e}");
1
}
}
} else if std::io::stdin().is_terminal() {
if let Some(dir) = frost_mcp::default_state_dir() {
let swept = frost_mcp::reap_dead(&dir, std::process::id(), &pid_is_alive);
if swept > 0 {
tracing::debug!(swept, "frost-mcp reaped dead shells' state files");
}
if let Err(e) = mcp_state.write_snapshot(&dir) {
tracing::debug!(error = %e, "frost-mcp snapshot write failed");
}
}
let _mcp_runtime = match frost_mcp::default_socket_path(std::process::id()) {
Some(socket_path) => match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.thread_name("frost-mcp")
.build()
{
Ok(rt) => {
let state: frost_mcp::SharedState =
std::sync::Arc::new(tokio::sync::RwLock::new(mcp_state));
let s = std::sync::Arc::clone(&state);
let path_for_task = socket_path.clone();
rt.spawn(async move {
if let Err(e) = frost_mcp::serve_uds(path_for_task, s).await {
tracing::warn!(error = %e, "frost-mcp UDS server exited");
}
});
Some(rt)
}
Err(e) => {
tracing::warn!(error = %e, "frost-mcp runtime build failed");
None
}
},
None => None,
};
interactive(
&mut env,
rc_completions,
rc_binds,
rc_descriptions,
rc_payloads,
rc_pickers,
rc_subcmds,
rc_flags,
rc_positionals,
rc_abbreviations,
rc_theme,
rc_multi_key,
);
0
} else {
let mut buf = String::new();
if std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf).is_ok() {
unwrap_outcome(run_script(&buf, &mut env))
} else {
1
}
};
run_exit_trap(&mut env);
process::exit(code);
}
fn unwrap_outcome(outcome: RunOutcome) -> i32 {
match outcome {
RunOutcome::Completed(c) | RunOutcome::Exit(c) => c,
}
}
fn run_hook(name: &str, env: &mut frost_exec::ShellEnv) {
if !env.functions.contains_key(name) {
return;
}
let _ = run(name, env);
}
fn run_exit_trap(env: &mut frost_exec::ShellEnv) {
let name = "__frost_trap_EXIT";
if env.functions.contains_key(name) {
let _ = run(name, env);
}
mcp_state_teardown();
}
fn mcp_state_teardown() {
if let Some(dir) = frost_mcp::default_state_dir() {
frost_mcp::remove_process_files(&dir, std::process::id());
}
}
fn pid_is_alive(pid: u32) -> bool {
let Ok(raw) = i32::try_from(pid) else {
return true;
};
if raw <= 0 {
return true;
}
if unsafe { libc::kill(raw, 0) } == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
#[cfg(test)]
mod tests {
use super::{format_chord, is_complete, last_argument};
#[test]
fn last_argument_plain_split() {
assert_eq!(last_argument("ls -la /tmp"), "/tmp");
assert_eq!(last_argument("single"), "single");
assert_eq!(last_argument(""), "");
assert_eq!(last_argument(" "), "");
}
#[test]
fn last_argument_trims_trailing_whitespace() {
assert_eq!(last_argument("echo hi\n"), "hi");
assert_eq!(last_argument("ls foo "), "foo");
}
#[test]
fn last_argument_preserves_trailing_quoted_group() {
assert_eq!(last_argument(r#"echo "hello world""#), r#""hello world""#);
assert_eq!(last_argument("grep 'needs quoting'"), "'needs quoting'");
}
#[test]
fn last_argument_single_quoted_string_only() {
assert_eq!(last_argument(r#""wholething""#), r#""wholething""#);
}
#[test]
fn last_argument_unbalanced_quote_falls_back_to_whitespace() {
assert_eq!(last_argument(r#"echo a b ""#), r#"""#);
}
#[test]
fn format_chord_bare_letter() {
use crossterm::event::{KeyCode, KeyModifiers};
assert_eq!(
format_chord(KeyCode::Char('e'), KeyModifiers::NONE),
Some("e".to_string())
);
}
#[test]
fn format_chord_ctrl_letter() {
use crossterm::event::{KeyCode, KeyModifiers};
assert_eq!(
format_chord(KeyCode::Char('x'), KeyModifiers::CONTROL),
Some("C-x".to_string())
);
}
#[test]
fn format_chord_alt_letter() {
use crossterm::event::{KeyCode, KeyModifiers};
assert_eq!(
format_chord(KeyCode::Char('?'), KeyModifiers::ALT),
Some("M-?".to_string())
);
}
#[test]
fn format_chord_named_key() {
use crossterm::event::{KeyCode, KeyModifiers};
assert_eq!(
format_chord(KeyCode::Tab, KeyModifiers::CONTROL),
Some("C-tab".to_string())
);
assert_eq!(
format_chord(KeyCode::Up, KeyModifiers::ALT),
Some("M-up".to_string())
);
assert_eq!(
format_chord(KeyCode::Enter, KeyModifiers::NONE),
Some("enter".to_string())
);
}
#[test]
fn format_chord_ctrl_and_alt_and_named() {
use crossterm::event::{KeyCode, KeyModifiers};
let m = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
assert_eq!(
format_chord(KeyCode::Home, m),
Some("C-M-S-home".to_string())
);
}
#[test]
fn format_chord_rejects_unknown_keys() {
use crossterm::event::{KeyCode, KeyModifiers};
assert_eq!(format_chord(KeyCode::F(5), KeyModifiers::NONE), None);
}
#[test]
fn simple_commands_are_complete() {
assert!(is_complete("echo hi"));
assert!(is_complete("ls | grep foo"));
assert!(is_complete("a=1; b=2"));
}
#[test]
fn trailing_backslash_is_incomplete() {
assert!(!is_complete("echo hi \\"));
assert!(!is_complete("ls \\"));
}
#[test]
fn unclosed_quotes_are_incomplete() {
assert!(!is_complete("echo 'hello"));
assert!(!is_complete("echo \"world"));
}
#[test]
fn unbalanced_brackets_are_incomplete() {
assert!(!is_complete("echo (nested"));
assert!(!is_complete("arr=(1 2 3"));
assert!(!is_complete("f() {"));
}
#[test]
fn if_requires_fi() {
assert!(!is_complete("if true"));
assert!(!is_complete("if true; then echo yes"));
assert!(is_complete("if true; then echo yes; fi"));
}
#[test]
fn while_requires_done() {
assert!(!is_complete("while true; do echo loop"));
assert!(is_complete("while true; do echo loop; done"));
}
#[test]
fn case_requires_esac() {
assert!(!is_complete("case $x in a) echo a ;;"));
assert!(is_complete("case $x in a) echo a ;; esac"));
}
#[test]
fn comments_do_not_affect_balance() {
assert!(is_complete("echo hi # a ( b { c ["));
}
}