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();