diff --git a/README.md b/README.md index 950fea8..f986e70 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Automated coverage includes inline unit tests, subprocess integration tests for - **Syntax-highlighted `cat`** — `cat` is aliased to `tcat`, which highlights files via `syntect` - **Syntax-highlighted diffs** — `tdiff` is set as `GIT_PAGER`, so `git diff`, `git show`, and `git log -p` all render with color and line-level highlights - **JSON prettifier** — `json pnpm dev` (or any command) runs it in a PTY so it sees a real terminal, then pretty-prints any JSON log lines while passing everything else through -- **Shell integration** — ZLE hooks report the input buffer and cursor position live; `chpwd` reports the working directory for dynamic tab titles +- **Shell integration** — ZLE hooks report the input buffer and cursor position live; `chpwd` reports the working directory for dynamic tab titles; mistyped commands show the top 3 likely matches - **Blinking cursor** — narrow 2px vertical bar, blinks at ~530 ms, resets on input - **zsh** with your real `~/.zshrc` and `~/.zshenv` sourced automatically diff --git a/src/command_suggest.rs b/src/command_suggest.rs new file mode 100644 index 0000000..8f86de8 --- /dev/null +++ b/src/command_suggest.rs @@ -0,0 +1,207 @@ +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Suggestion { + name: String, + normalized: String, + distance: usize, + starts_match: bool, + subsequence_match: bool, + char_overlap: usize, + common_prefix: usize, + length_diff: usize, +} + +pub fn suggest_commands(query: &str, candidates: I, limit: usize) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + if limit == 0 { + return Vec::new(); + } + + let normalized_query = normalize(query); + if normalized_query.is_empty() { + return Vec::new(); + } + let max_distance = max_distance(normalized_query.chars().count()); + + let mut seen = HashSet::new(); + let mut suggestions = Vec::new(); + for candidate in candidates { + let candidate = candidate.as_ref().trim(); + let normalized = normalize(candidate); + if !is_useful_candidate(candidate, &normalized) || normalized == normalized_query { + continue; + } + if !seen.insert(normalized.clone()) { + continue; + } + + let distance = damerau_levenshtein(&normalized_query, &normalized); + let starts_match = + normalized.starts_with(&normalized_query) || normalized_query.starts_with(&normalized); + let subsequence_match = is_subsequence(&normalized_query, &normalized) + || is_subsequence(&normalized, &normalized_query); + let char_overlap = shared_char_count(&normalized_query, &normalized); + let common_prefix = common_prefix_len(&normalized_query, &normalized); + let length_diff = normalized_query + .chars() + .count() + .abs_diff(normalized.chars().count()); + + if distance > max_distance && !starts_match && !subsequence_match && common_prefix < 2 { + continue; + } + + suggestions.push(Suggestion { + name: candidate.to_string(), + normalized, + distance, + starts_match, + subsequence_match, + char_overlap, + common_prefix, + length_diff, + }); + } + + suggestions.sort_by(|a, b| { + a.distance + .cmp(&b.distance) + .then_with(|| b.starts_match.cmp(&a.starts_match)) + .then_with(|| b.char_overlap.cmp(&a.char_overlap)) + .then_with(|| b.common_prefix.cmp(&a.common_prefix)) + .then_with(|| b.subsequence_match.cmp(&a.subsequence_match)) + .then_with(|| a.length_diff.cmp(&b.length_diff)) + .then_with(|| a.normalized.cmp(&b.normalized)) + }); + + suggestions + .into_iter() + .take(limit) + .map(|s| s.name) + .collect() +} + +fn normalize(s: &str) -> String { + s.trim().to_ascii_lowercase() +} + +fn is_useful_candidate(candidate: &str, normalized: &str) -> bool { + !candidate.is_empty() + && !normalized.is_empty() + && !candidate.starts_with('_') + && normalized.chars().any(|c| c.is_ascii_alphanumeric()) + && normalized + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.')) +} + +fn max_distance(query_len: usize) -> usize { + match query_len { + 0..=4 => 1, + 5..=8 => 2, + _ => 3, + } +} + +fn common_prefix_len(a: &str, b: &str) -> usize { + a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count() +} + +fn is_subsequence(needle: &str, haystack: &str) -> bool { + let mut needle = needle.chars(); + let mut current = needle.next(); + if current.is_none() { + return true; + } + for ch in haystack.chars() { + if Some(ch) == current { + current = needle.next(); + if current.is_none() { + return true; + } + } + } + false +} + +fn shared_char_count(a: &str, b: &str) -> usize { + let mut counts = [0u8; 128]; + for ch in a.bytes() { + if (ch as usize) < counts.len() { + counts[ch as usize] = counts[ch as usize].saturating_add(1); + } + } + + let mut shared = 0usize; + for ch in b.bytes() { + if (ch as usize) < counts.len() && counts[ch as usize] > 0 { + counts[ch as usize] -= 1; + shared += 1; + } + } + shared +} + +fn damerau_levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + + for (i, row) in dp.iter_mut().enumerate() { + row[0] = i; + } + for (j, cell) in dp[0].iter_mut().enumerate() { + *cell = j; + } + + for i in 1..=a.len() { + for j in 1..=b.len() { + let substitution_cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (dp[i - 1][j] + 1) + .min(dp[i][j - 1] + 1) + .min(dp[i - 1][j - 1] + substitution_cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(dp[i - 2][j - 2] + 1); + } + dp[i][j] = best; + } + } + + dp[a.len()][b.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggests_closest_command_first() { + let suggestions = + suggest_commands("cargu", ["cargo", "curl", "cargo-clippy", "cargo-fmt"], 3); + assert_eq!(suggestions[0], "cargo"); + assert_eq!(suggestions.len(), 3); + } + + #[test] + fn handles_transposed_letters() { + let suggestions = suggest_commands("gti", ["git", "go", "grep"], 3); + assert_eq!(suggestions, vec!["git"]); + } + + #[test] + fn filters_unhelpful_internal_names() { + let suggestions = suggest_commands("josn", ["_json", "[", "json", "join"], 3); + assert_eq!(suggestions[0], "json"); + } + + #[test] + fn respects_limit_after_sorting() { + let suggestions = suggest_commands("nodee", ["node", "nodejs", "nodenv", "npm"], 2); + assert_eq!(suggestions.len(), 2); + assert_eq!(suggestions[0], "node"); + } +} diff --git a/src/main.rs b/src/main.rs index 6566ad2..e43cb15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod command_suggest; mod completion; mod config; mod platform; @@ -2249,6 +2250,7 @@ fn sh_sq_escape(s: &str) -> String { #[derive(Debug, Clone, Default, PartialEq, Eq)] struct ShellTools { + term: Option, tcat: Option, tdiff: Option, tjson: Option, @@ -2275,6 +2277,7 @@ fn discover_shell_tools(exe_dir: Option<&Path>) -> ShellTools { path.exists().then_some(path) }; ShellTools { + term: tool("term"), tcat: tool("tcat"), tdiff: tool("tdiff"), tjson: tool("tjson"), @@ -2306,6 +2309,56 @@ fn build_shell_bootstrap_files(home: &str, tools: &ShellTools) -> ShellBootstrap ), None => String::new(), }; + let suggest_extra_candidates = format!( + "{}{}", + if tools.tcat.is_some() { + " print -rl -- cat\n" + } else { + "" + }, + if tools.tjson.is_some() { + " print -rl -- json\n" + } else { + "" + } + ); + let command_not_found_fn = match tools.term.as_ref() { + Some(path) => format!( + "_TERM_SELF='{}'\n\ + function command_not_found_handler() {{\n\ + emulate -L zsh\n\ + local cmd=\"$1\"\n\ + shift\n\ + print -u2 -- \"zsh: command not found: $cmd\"\n\ + if [[ -x \"$_TERM_SELF\" ]]; then\n\ + local -a suggestions\n\ + suggestions=(${{(@f)$({{\n\ + print -rl -- ${{(k)commands}}\n\ + print -rl -- ${{(k)builtins}}\n\ +{suggest_extra_candidates} }} | LC_ALL=C sort -u | \"$_TERM_SELF\" __suggest_command \"$cmd\" 2>/dev/null)}})\n\ + if (( ${{#suggestions[@]}} > 0 )); then\n\ + print -u2 -- 'Did you mean:'\n\ + local suggestion\n\ + for suggestion in $suggestions; do\n\ + print -u2 -- \" $suggestion\"\n\ + done\n\ + local -a retry_words\n\ + retry_words=(\"${{suggestions[1]}}\" \"$@\")\n\ + local retry_display=\"${{(j: :)retry_words}}\"\n\ + if read -q \"reply?try '$retry_display'? y/n \"; then\n\ + print -u2\n\ + \"$retry_words[@]\"\n\ + return $?\n\ + fi\n\ + print -u2\n\ + fi\n\ + fi\n\ + return 127\n\ + }}\n", + sh_sq_escape(&path.display().to_string()) + ), + None => String::new(), + }; let zle_hooks = r#" _term_buf_report() { printf '\033]9001;%s\034%d\033\\' "$BUFFER" "$CURSOR"; } @@ -2341,7 +2394,7 @@ zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' "ZDOTDIR='{home}'\n\ [ -f '{home}/.zprofile' ] && source '{home}/.zprofile'\n\ [ -f '{home}/.zshrc' ] && source '{home}/.zshrc'\n\ - {cat_fn}{diff_fn}{json_fn}{zle_hooks}{prompt_setup}" + {cat_fn}{diff_fn}{json_fn}{command_not_found_fn}{zle_hooks}{prompt_setup}" ); ShellBootstrapFiles { zshenv, zshrc } @@ -2406,9 +2459,41 @@ fn setup_shell_env(cmd: &mut CommandBuilder) { } } +fn maybe_run_helper_mode() -> Option { + let mut args = std::env::args(); + let _exe = args.next(); + match args.next().as_deref() { + Some("__suggest_command") => { + let Some(query) = args.next() else { + eprintln!("term: missing command name for __suggest_command"); + return Some(2); + }; + if args.next().is_some() { + eprintln!("term: too many arguments for __suggest_command"); + return Some(2); + } + + let mut candidates = String::new(); + if std::io::stdin().read_to_string(&mut candidates).is_err() { + eprintln!("term: failed to read command candidates"); + return Some(1); + } + + for suggestion in command_suggest::suggest_commands(&query, candidates.lines(), 3) { + println!("{suggestion}"); + } + Some(0) + } + _ => None, + } +} + // ── Entry point ─────────────────────────────────────────────────────────────── fn main() { + if let Some(code) = maybe_run_helper_mode() { + std::process::exit(code); + } let event_loop = EventLoop::::with_user_event() .build() .expect("event loop"); @@ -2948,10 +3033,12 @@ mod tests { #[test] fn discover_shell_tools_finds_existing_neighbors() { let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("term"), b"").unwrap(); std::fs::write(dir.path().join("tcat"), b"").unwrap(); std::fs::write(dir.path().join("tdiff"), b"").unwrap(); let tools = discover_shell_tools(Some(dir.path())); + assert_eq!(tools.term, Some(dir.path().join("term"))); assert_eq!(tools.tcat, Some(dir.path().join("tcat"))); assert_eq!(tools.tdiff, Some(dir.path().join("tdiff"))); assert_eq!(tools.tjson, None); @@ -2960,6 +3047,7 @@ mod tests { #[test] fn build_shell_bootstrap_files_include_aliases_hooks_and_escaping() { let tools = ShellTools { + term: Some(PathBuf::from("/tmp/O'Brien/bin/term")), tcat: Some(PathBuf::from("/tmp/O'Brien/bin/tcat")), tdiff: Some(PathBuf::from("/tmp/O'Brien/bin/tdiff")), tjson: Some(PathBuf::from("/tmp/O'Brien/bin/tjson")), @@ -2967,6 +3055,11 @@ mod tests { let files = build_shell_bootstrap_files("/Users/O'Brien", &tools); assert!(files.zshenv.contains("[ -f '/Users/O'\\''Brien/.zshenv' ]")); + assert!( + files + .zshrc + .contains("_TERM_SELF='/tmp/O'\\''Brien/bin/term'") + ); assert!(files.zshrc.contains("_TCAT='/tmp/O'\\''Brien/bin/tcat'")); assert!( files @@ -2976,6 +3069,19 @@ mod tests { assert!(files.zshrc.contains("_TJSON='/tmp/O'\\''Brien/bin/tjson'")); assert!(files.zshrc.contains("function cat()")); assert!(files.zshrc.contains("function json()")); + assert!(files.zshrc.contains("function command_not_found_handler()")); + assert!(files.zshrc.contains("__suggest_command \"$cmd\"")); + assert!( + files + .zshrc + .contains("retry_words=(\"${suggestions[1]}\" \"$@\")") + ); + assert!( + files + .zshrc + .contains("read -q \"reply?try '$retry_display'? y/n \"") + ); + assert!(files.zshrc.contains("\"$retry_words[@]\"")); assert!( files .zshrc diff --git a/tests/subprocess_binaries.rs b/tests/subprocess_binaries.rs index b6cc601..a189061 100644 --- a/tests/subprocess_binaries.rs +++ b/tests/subprocess_binaries.rs @@ -6,6 +6,7 @@ use tempfile::TempDir; fn bin_path(name: &str) -> &'static str { match name { + "term" => env!("CARGO_BIN_EXE_term"), "tcat" => env!("CARGO_BIN_EXE_tcat"), "tdiff" => env!("CARGO_BIN_EXE_tdiff"), "tjson" => env!("CARGO_BIN_EXE_tjson"), @@ -357,3 +358,18 @@ fn tjson_pty_mode_returns_failure_for_nonzero_child() { let output = run_bin("tjson", &["/bin/sh", "-lc", "exit 7"], None, None); assert_eq!(output.status.code(), Some(1), "{output:?}"); } + +#[test] +fn term_hidden_command_suggester_returns_best_matches() { + let output = run_bin( + "term", + &["__suggest_command", "cargu"], + Some("cargo\ncargo-clippy\ncargo-fmt\ncurl\ngit\n"), + None, + ); + let stdout = String::from_utf8(output.stdout.clone()).unwrap(); + let suggestions: Vec<_> = stdout.lines().collect(); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(suggestions, vec!["cargo", "cargo-fmt", "cargo-clippy"]); +}