diff --git a/Cargo.lock b/Cargo.lock index b8ab949..cdf93a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1240,6 +1240,7 @@ dependencies = [ "miette", "parse_datetime", "rustyline", + "serde_json", "tokio", "uu_date", "uu_ls", diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index ee5449c..1799359 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -30,6 +30,7 @@ rustyline = { version = "14.0.0", features = ["derive"] } tokio = "1.40.0" uu_ls = "0.0.27" dirs = "5.0.1" +serde_json = "1.0.128" which = "6.0.3" uu_uname = "0.0.27" uu_touch = "0.0.27" diff --git a/crates/shell/data/completions/git.json b/crates/shell/data/completions/git.json new file mode 100644 index 0000000..7c31118 --- /dev/null +++ b/crates/shell/data/completions/git.json @@ -0,0 +1,43 @@ +{ + "git": { + "add": { + "$exec": "git ls-files --others --exclude-standard" + }, + "commit": { + "-m": { + "$input": "Enter commit message" + } + }, + "branch": { + "$exec": "git branch --list | sed 's/^[* ] //'" + }, + "checkout": { + "$exec": "git branch --list | sed 's/^[* ] //'" + }, + "push": { + "origin": { + "$exec": "git branch --show-current" + } + }, + "pull": { + "origin": { + "$exec": "git branch --show-current" + } + }, + "status": {}, + "log": {}, + "rebase": { + "-i": {"$exec": "git log --pretty=format:'%h' -n 30"}, + "--interactive": {"$exec": "git log --pretty=format:'%h' -n 30"}, + "--continue": {}, + "--abort": {}, + "--skip": {}, + "--edit-todo": {}, + "--onto": { + "$exec": "git branch --list | sed 's/^[* ] //'", + "$after": {"$exec": "git log --pretty=format:'%h' -n 30"} + }, + "$default": {"$exec": "git branch --list | sed 's/^[* ] //'"} + } + } +} \ No newline at end of file diff --git a/crates/shell/src/completion.rs b/crates/shell/src/completion.rs index 5410797..0206620 100644 --- a/crates/shell/src/completion.rs +++ b/crates/shell/src/completion.rs @@ -5,11 +5,34 @@ use rustyline::hint::Hinter; use rustyline::validate::Validator; use rustyline::{Context, Helper}; use std::borrow::Cow::{self, Owned}; +use std::collections::HashMap; use std::env; use std::fs; use std::path::Path; +use std::process::Command; -pub struct ShellCompleter; +pub struct ShellCompleter { + generic_completions: HashMap, +} + +impl ShellCompleter { + pub fn new() -> Self { + let mut generic_completions = HashMap::new(); + + let contents = include_str!("../data/completions/git.json"); + if let Ok(json) = serde_json::from_str(&contents) { + if let serde_json::Value::Object(map) = json { + for (key, value) in map { + generic_completions.insert(key, value); + } + } + } + + ShellCompleter { + generic_completions, + } + } +} impl Default for ShellCompleter { fn default() -> Self { @@ -30,6 +53,15 @@ impl Completer for ShellCompleter { let (start, word) = extract_word(line, pos); let is_start = start == 0; + + let parts: Vec<&str> = line[..pos].split_whitespace().collect(); + // Complete generic commands (including git) + if !parts.is_empty() && self.generic_completions.contains_key(parts[0]) { + complete_generic_commands(self, line, pos, &mut matches); + let start = line[..pos].rfind(char::is_whitespace).map_or(0, |i| i + 1); + return Ok((start, matches)); + } + // Complete filenames complete_filenames(is_start, word, &mut matches); @@ -132,6 +164,85 @@ fn complete_executables_in_path(is_start: bool, word: &str, matches: &mut Vec) { + let parts: Vec<&str> = line[..pos].split_whitespace().collect(); + if parts.is_empty() { + return; + } + + let command = parts[0]; + if let Some(completions) = completer.generic_completions.get(command) { + let mut current = completions; + let mut partial = ""; + + for (i, part) in parts.iter().enumerate().skip(1) { + if i == parts.len() - 1 && !line.ends_with(" ") { + partial = part; + break; + } + + if let Some(next) = current.get(part) { + current = next; + } else { + return; + } + } + + if let Some(default) = current.get("$default") { + current = default; + } + + match current { + serde_json::Value::Object(map) => { + if let Some(exec) = map.get("$exec") { + if let Some(cmd) = exec.as_str() { + let output = Command::new("sh") + .arg("-c") + .arg(cmd) + .output() + .expect("Failed to execute command"); + let completions = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|s| s.starts_with(partial)) + .map(|s| Pair { + display: s.to_string(), + replacement: s.to_string(), + }) + .collect::>(); + matches.extend(completions); + } + } else if let Some(input) = map.get("$input") { + if let Some(prompt) = input.as_str() { + println!("{}", prompt); + } + } else { + for key in map.keys() { + if key.starts_with(partial) && *key != "$exec" && *key != "$input" && *key != "$default" { + matches.push(Pair { + display: key.clone(), + replacement: key.clone(), + }); + } + } + } + } + serde_json::Value::Array(arr) => { + for item in arr { + if let Some(s) = item.as_str() { + if s.starts_with(partial) { + matches.push(Pair { + display: s.to_string(), + replacement: s.to_string(), + }); + } + } + } + } + _ => {} + } + } +} + impl Hinter for ShellCompleter { type Hint = String; } diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index dc8b27e..a1b92bc 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -41,6 +41,7 @@ async fn interactive() -> miette::Result<()> { }) .expect("Error setting Ctrl-C handler"); + let h = ShellCompleter::new(); let mut rl = Editor::with_config(config).into_diagnostic()?; let helper = helper::ShellPromptHelper::default();