Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<color>…</color>` tags. Pieces
are expanded once as `{{name}}`.
Expand Down
77 changes: 76 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,33 @@ impl rustyline::hint::Hinter for AeroHelper {
type Hint = String;

fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<String> {
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<String> {
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,
Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions src/packages/lua_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ pub fn completions_for(command: &str) -> Vec<String> {
.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<String> {
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<String>)> {
let completions = COMPLETIONS.lock().expect("completion lock poisoned");
let mut entries: Vec<(String, Vec<String>)> = completions
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand Down
Loading