diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..da57430
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Build and test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Test
+ run: cargo test
+ - name: Release build
+ run: cargo build --release
diff --git a/docs/packages.md b/docs/packages.md
index a85ea87..dc4dcda 100644
--- a/docs/packages.md
+++ b/docs/packages.md
@@ -135,10 +135,17 @@ stage in a pipeline yet.
| `prompt.set_template(template)` | Set and persist the prompt template |
| `prompt.color(name, code)` | Alias for `colors.set` |
| `prompt.add_piece(name, template)` | Register `{{name}}` for prompt templates |
-| `completion.add(command, { … })` | Argument candidates for Tab completion |
+| `completion.add(command, { … })` | Argument candidates for Tab completion and inline preview |
| `env.get/set/unset` | Session environment |
| `hooks.register(name, fn)` | Register a hook (see below) |
+Registered candidates are also previewed inline in the dim suggestion color
+while you type the argument: the rest of the first matching candidate is shown
+and can be accepted with `→`, `End`, or `Ctrl-F`. Tab completion still lists
+every match.
+Only the in-memory candidate list is consulted, so typing never triggers
+package code or filesystem work.
+
Prompt placeholders are shared with the shell: `{username} {hostname} {pwd}
{short_pwd} {time} {date} {battery} {git}`, plus `…` tags. Pieces
are expanded once as `{{name}}`.
diff --git a/src/main.rs b/src/main.rs
index 52bd964..b8ea8e3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -170,12 +170,33 @@ impl rustyline::hint::Hinter for AeroHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option {
- let hint = history_suggestion(line, pos, ctx.history(), ctx.history_index());
+ // A package completion is context-specific, so it wins over a history
+ // suggestion when both apply. Only registered, in-memory candidates
+ // are considered here; filesystem completion stays on Tab.
+ let hint = package_completion_hint(line, pos)
+ .or_else(|| history_suggestion(line, pos, ctx.history(), ctx.history_index()));
self.hint_state.set(hint.is_some());
hint
}
}
+// Preview the rest of a package-provided argument candidate while the word is
+// being typed. Package completions are a plain in-memory lookup, so this is
+// safe to run on every keystroke; path candidates are not (they read the
+// filesystem) and are left to Tab completion.
+fn package_completion_hint(line: &str, pos: usize) -> Option {
+ if line.is_empty() || pos < line.len() || line.contains('\n') {
+ return None;
+ }
+ let start = line[..pos].rfind(char::is_whitespace).map_or(0, |i| i + 1);
+ let word = &line[start..pos];
+ if word.is_empty() {
+ return None;
+ }
+ let head = line[..start].split_whitespace().next()?;
+ packages::lua_api::completion_preview(head, word)
+}
+
fn history_suggestion(
line: &str,
pos: usize,
@@ -2328,6 +2349,60 @@ mod tests {
);
}
+ fn set_completions(command: &str, candidates: &[&str]) {
+ packages::lua_api::COMPLETIONS.lock().unwrap().insert(
+ command.to_string(),
+ candidates.iter().map(|s| s.to_string()).collect(),
+ );
+ }
+
+ fn clear_completions(command: &str) {
+ packages::lua_api::COMPLETIONS
+ .lock()
+ .unwrap()
+ .remove(command);
+ }
+
+ #[test]
+ fn package_completion_hint_previews_the_first_matching_candidate() {
+ let command = "preview-demo";
+ set_completions(command, &["beta", "alpine", "alpha"]);
+
+ let hint = |line: &str| package_completion_hint(line, line.len());
+ assert_eq!(hint("preview-demo al"), Some("pha".into()));
+ assert_eq!(hint("preview-demo a"), Some("lpha".into()));
+ assert_eq!(hint("preview-demo alpha"), None);
+ assert_eq!(hint("preview-demo zz"), None);
+ assert_eq!(hint("preview-demo "), None);
+ assert_eq!(package_completion_hint("preview-demo al", 14), None);
+ assert_eq!(hint("preview-demo"), None);
+
+ clear_completions(command);
+ }
+
+ #[test]
+ fn package_completion_hint_wins_over_history_but_falls_back_to_it() {
+ let command = "preview-priority";
+ set_completions(command, &["staging"]);
+
+ let line = "preview-priority st";
+ assert_eq!(
+ hint_for(line, line.len(), &["preview-priority status"]),
+ Some("aging".into())
+ );
+
+ let line = "preview-priority x";
+ assert_eq!(
+ hint_for(line, line.len(), &["preview-priority xyz"]),
+ Some("yz".into())
+ );
+
+ let line = "preview-priority st";
+ assert_eq!(hint_for(line, line.len(), &[]), Some("aging".into()));
+
+ clear_completions(command);
+ }
+
#[test]
fn moving_off_the_end_marks_the_hint_hidden() {
let helper = AeroHelper {
diff --git a/src/packages/lua_api.rs b/src/packages/lua_api.rs
index 9922522..dcdd5b1 100644
--- a/src/packages/lua_api.rs
+++ b/src/packages/lua_api.rs
@@ -277,6 +277,30 @@ pub fn completions_for(command: &str) -> Vec {
.unwrap_or_default()
}
+/// The rest of the first matching candidate that extends `prefix`, if one
+/// exists. Candidates are ordered the way Tab lists them.
+///
+/// This only reads the in-memory completion table; it never runs package code
+/// or touches the filesystem, so the line editor can call it on every
+/// keystroke. Path and command-name completions are deliberately excluded
+/// because those are not cheap to compute.
+pub fn completion_preview(command: &str, prefix: &str) -> Option {
+ if prefix.is_empty() {
+ return None;
+ }
+ let completions = COMPLETIONS.lock().ok()?;
+ let candidates = completions.get(command)?;
+ let candidate = candidates
+ .iter()
+ .filter(|candidate| candidate.len() > prefix.len() && candidate.starts_with(prefix))
+ .min()?;
+ let suffix = &candidate[prefix.len()..];
+ if suffix.contains('\n') || suffix.contains('\r') {
+ return None;
+ }
+ Some(suffix.to_string())
+}
+
pub fn all_completions() -> Vec<(String, Vec)> {
let completions = COMPLETIONS.lock().expect("completion lock poisoned");
let mut entries: Vec<(String, Vec)> = completions
@@ -633,6 +657,10 @@ fn hook_register(lua: &Lua, (name, function): (String, LuaFunction)) -> LuaResul
mod tests {
use super::*;
+ // Tests that inspect the shared completion table reset it, so they must
+ // not run in parallel with each other.
+ static COMPLETION_TESTS: Mutex<()> = Mutex::new(());
+
fn temp_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ash-lua-test-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
@@ -680,6 +708,7 @@ end
#[test]
fn completion_definitions_populate_candidates() {
+ let _guard = COMPLETION_TESTS.lock().unwrap();
reset_runtime().unwrap();
reset_completions();
let dir = temp_dir("completion");
@@ -691,6 +720,43 @@ end
assert!(completions_for("other").is_empty());
}
+ #[test]
+ fn completion_preview_extends_the_first_matching_candidate() {
+ let _guard = COMPLETION_TESTS.lock().unwrap();
+ let command = "preview-test-command";
+ COMPLETIONS.lock().unwrap().insert(
+ command.to_string(),
+ vec![
+ "beta".to_string(),
+ "alpine".to_string(),
+ "alpha".to_string(),
+ ],
+ );
+
+ assert_eq!(completion_preview(command, "al"), Some("pha".to_string()));
+ assert_eq!(completion_preview(command, "alp"), Some("ha".to_string()));
+ assert_eq!(completion_preview(command, "alpha"), None);
+ assert_eq!(completion_preview(command, "zzz"), None);
+ assert_eq!(completion_preview(command, ""), None);
+ assert_eq!(completion_preview("other-command", "al"), None);
+
+ COMPLETIONS.lock().unwrap().remove(command);
+ }
+
+ #[test]
+ fn completion_preview_skips_candidates_that_would_break_the_line() {
+ let _guard = COMPLETION_TESTS.lock().unwrap();
+ let command = "preview-multiline-command";
+ COMPLETIONS
+ .lock()
+ .unwrap()
+ .insert(command.to_string(), vec!["alpha\nbeta".to_string()]);
+
+ assert_eq!(completion_preview(command, "al"), None);
+
+ COMPLETIONS.lock().unwrap().remove(command);
+ }
+
#[test]
fn invalid_hook_names_are_rejected() {
reset_runtime().unwrap();