From 879f87cbc2b89d31efbded23341a6f050cbcbf7c Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 7 Sep 2024 06:36:49 -0400 Subject: [PATCH] start adding a little custom completion framework --- Cargo.lock | 1 + crates/shell/Cargo.toml | 1 + crates/shell/data/completions/git.json | 43 ++++++++++ crates/shell/src/completion.rs | 113 ++++++++++++++++++++++++- crates/shell/src/main.rs | 2 +- 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 crates/shell/data/completions/git.json diff --git a/Cargo.lock b/Cargo.lock index fe91806..5ee11c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "dirs", "futures", "rustyline", + "serde_json", "tokio", "uu_ls", ] diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index c2029e6..5b97bf7 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -27,6 +27,7 @@ rustyline = "14.0.0" tokio = "1.39.2" uu_ls = "0.0.27" dirs = "5.0.1" +serde_json = "1.0.128" [package.metadata.release] # Dont publish the binary 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 70d7a52..232b767 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 Completer for ShellCompleter { type Candidate = Pair; @@ -24,6 +47,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); @@ -123,6 +155,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 4674a7d..68e82a8 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -76,7 +76,7 @@ async fn interactive() -> anyhow::Result<()> { let mut rl = Editor::with_config(config)?; - let h = ShellCompleter {}; + let h = ShellCompleter::new(); rl.set_helper(Some(h));