feat: add dynamic shell completions for task keys and backlog names
Uses clap_complete's unstable-dynamic feature so the shell calls
back into the binary on <TAB>, querying the database for live
task key and backlog name candidates.
CompleteEnv runs before the tokio runtime to avoid nested-runtime
panics from the synchronous completer callbacks.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/Cargo.lock b/Cargo.lock
index 7d1fb72..1d79cf0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -289,6 +289,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
dependencies = [
"clap",
+ "clap_lex",
+ "is_executable",
+ "shlex",
]
[[package]]
@@ -939,6 +942,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "is_executable"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
diff --git a/Cargo.toml b/Cargo.toml
index 30c0a53..c448719 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,8 +12,8 @@ axum = "*"
color-eyre = "*"
tracing-subscriber = { version = "*", features = ["env-filter"] }
jiff = { version = "*", features = ["serde"] }
-clap = { version = "*", features = ["derive", "env"] }
-clap_complete = "*"
+clap = { version = "*", features = ["derive", "env", "unstable-ext"] }
+clap_complete = { version = "*", features = ["unstable-dynamic"] }
rand = "*"
serde = { version = "*", features = ["derive"] }
serde_json = "*"
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 7ff4f84..a9cfab4 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -1,4 +1,5 @@
use clap::Subcommand;
+use clap_complete::engine::ArgValueCompleter;
use color_eyre::eyre::Result;
use ranger::db::SqlitePool;
use ranger::key;
@@ -6,6 +7,7 @@ use ranger::models::{Backlog, State};
use ranger::ops;
use ranger::ops::task::ListFilter;
+use crate::completions;
use crate::output;
#[derive(Subcommand)]
@@ -23,7 +25,7 @@ pub enum BacklogCommands {
#[command(visible_alias = "s")]
Show {
/// Backlog name
- #[arg(env = "RANGER_DEFAULT_BACKLOG")]
+ #[arg(env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
name: String,
/// Show only done tasks
@@ -33,7 +35,7 @@ pub enum BacklogCommands {
/// Rebalance task positions in a backlog
Rebalance {
/// Backlog name
- #[arg(env = "RANGER_DEFAULT_BACKLOG")]
+ #[arg(env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
name: String,
},
}
diff --git a/src/bin/ranger/commands/comment.rs b/src/bin/ranger/commands/comment.rs
index 9a5bf08..4955c68 100644
--- a/src/bin/ranger/commands/comment.rs
+++ b/src/bin/ranger/commands/comment.rs
@@ -1,8 +1,10 @@
use clap::Subcommand;
+use clap_complete::engine::ArgValueCompleter;
use color_eyre::eyre::Result;
use ranger::db::SqlitePool;
use ranger::ops;
+use crate::completions;
use crate::output;
#[derive(Subcommand)]
@@ -11,6 +13,7 @@ pub enum CommentCommands {
#[command(visible_alias = "a")]
Add {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
task: String,
/// Comment body
body: String,
@@ -19,6 +22,7 @@ pub enum CommentCommands {
#[command(visible_alias = "ls")]
List {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
task: String,
},
}
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index c172cbb..bd28e17 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use clap::{Args, Subcommand};
+use clap_complete::engine::ArgValueCompleter;
use color_eyre::eyre::{Result, bail};
use ranger::db::{SqliteConnection, SqlitePool};
use ranger::key;
@@ -8,16 +9,17 @@ use ranger::models::{State, Task};
use ranger::ops;
use ranger::ops::task::{ListFilter, Placement};
+use crate::completions;
use crate::output;
/// Positioning flags shared by create, edit, and move.
#[derive(Args)]
pub struct PositionArgs {
/// Place before this task key
- #[arg(long, short = 'B')]
+ #[arg(long, short = 'B', add = ArgValueCompleter::new(completions::complete_task_keys))]
before: Option<String>,
/// Place after this task key
- #[arg(long, short = 'A')]
+ #[arg(long, short = 'A', add = ArgValueCompleter::new(completions::complete_task_keys))]
after: Option<String>,
}
@@ -70,7 +72,7 @@ pub enum TaskCommands {
/// Task title
title: String,
/// Backlog name
- #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
+ #[arg(long, env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
backlog: String,
/// Task description
#[arg(long)]
@@ -79,7 +81,7 @@ pub enum TaskCommands {
#[arg(long)]
state: Option<String>,
/// Parent task key or prefix (makes this a subtask)
- #[arg(long)]
+ #[arg(long, add = ArgValueCompleter::new(completions::complete_task_keys))]
parent: Option<String>,
#[command(flatten)]
position: PositionArgs,
@@ -88,7 +90,7 @@ pub enum TaskCommands {
#[command(visible_alias = "ls")]
List {
/// Filter by backlog name
- #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
+ #[arg(long, env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
backlog: Option<String>,
/// Filter by state
#[arg(long)]
@@ -104,12 +106,14 @@ pub enum TaskCommands {
#[command(visible_alias = "s")]
Show {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
},
/// Edit a task
#[command(visible_alias = "e")]
Edit {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
/// New title
#[arg(long)]
@@ -127,6 +131,7 @@ pub enum TaskCommands {
#[command(visible_alias = "mv")]
Move {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
#[command(flatten)]
position: PositionArgs,
@@ -136,18 +141,21 @@ pub enum TaskCommands {
#[command(visible_alias = "del")]
Delete {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
},
/// Archive a task
Archive {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
},
/// Unarchive a task
Unarchive {
/// Task key or prefix
+ #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
},
}
diff --git a/src/bin/ranger/completions.rs b/src/bin/ranger/completions.rs
new file mode 100644
index 0000000..3bd87de
--- /dev/null
+++ b/src/bin/ranger/completions.rs
@@ -0,0 +1,91 @@
+use std::ffi::OsStr;
+use std::path::PathBuf;
+
+use clap_complete::engine::CompletionCandidate;
+
+/// Resolve the database path the same way `main` does: `RANGER_DB` env var, else XDG default.
+fn resolve_db_path() -> Option<PathBuf> {
+ if let Ok(path) = std::env::var("RANGER_DB") {
+ return Some(PathBuf::from(path));
+ }
+ let xdg = xdg::BaseDirectories::with_prefix("ranger").ok()?;
+ xdg.place_data_file("ranger.db").ok()
+}
+
+/// Run an async closure on a fresh single-threaded tokio runtime.
+/// Returns `None` if the runtime can't be created.
+fn block_on<F, T>(f: F) -> Option<T>
+where
+ F: std::future::Future<Output = Option<T>>,
+{
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .ok()?
+ .block_on(f)
+}
+
+/// Complete task keys, returning full keys with the task title as help text.
+pub fn complete_task_keys(current: &OsStr) -> Vec<CompletionCandidate> {
+ let Some(current) = current.to_str() else {
+ return vec![];
+ };
+ let Some(db_path) = resolve_db_path() else {
+ return vec![];
+ };
+ if !db_path.exists() {
+ return vec![];
+ }
+
+ block_on(async {
+ let pool = ranger::db::connect(&db_path).await.ok()?;
+ let mut conn = pool.acquire().await.ok()?;
+
+ let rows: Vec<(String, String, String)> =
+ sqlx::query_as("SELECT key, title, state FROM tasks ORDER BY key")
+ .fetch_all(&mut *conn)
+ .await
+ .ok()?;
+
+ Some(
+ rows.into_iter()
+ .filter(|(key, _, _)| key.starts_with(current))
+ .map(|(key, title, state)| {
+ CompletionCandidate::new(key).help(Some(format!("[{state}] {title}").into()))
+ })
+ .collect(),
+ )
+ })
+ .unwrap_or_default()
+}
+
+/// Complete backlog names.
+pub fn complete_backlog_names(current: &OsStr) -> Vec<CompletionCandidate> {
+ let Some(current) = current.to_str() else {
+ return vec![];
+ };
+ let Some(db_path) = resolve_db_path() else {
+ return vec![];
+ };
+ if !db_path.exists() {
+ return vec![];
+ }
+
+ block_on(async {
+ let pool = ranger::db::connect(&db_path).await.ok()?;
+ let mut conn = pool.acquire().await.ok()?;
+
+ let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM backlogs ORDER BY name")
+ .fetch_all(&mut *conn)
+ .await
+ .ok()?;
+
+ Some(
+ rows.into_iter()
+ .filter(|(name,)| name.starts_with(current))
+ .map(|(name,)| CompletionCandidate::new(name))
+ .collect(),
+ )
+ })
+ .unwrap_or_default()
+}
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index 2087e56..f199958 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -1,8 +1,9 @@
mod commands;
+mod completions;
mod output;
use clap::{CommandFactory, Parser, Subcommand};
-use clap_complete::Shell;
+use clap_complete::engine::ArgValueCompleter;
use std::path::PathBuf;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
@@ -47,18 +48,13 @@ enum Commands {
#[command(subcommand)]
command: commands::tag::TagCommands,
},
- /// Generate shell completions
- Completions {
- /// Shell to generate completions for
- shell: Shell,
- },
/// Start the web server
Serve {
/// Port to listen on
#[arg(long, default_value_t = 3000)]
port: u16,
/// Backlog to display
- #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
+ #[arg(long, env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
backlog: String,
},
}
@@ -72,23 +68,26 @@ fn resolve_db_path(cli_path: Option<PathBuf>) -> PathBuf {
.expect("failed to create data directory")
}
-#[tokio::main]
-async fn main() -> color_eyre::Result<()> {
+fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
- let cli = Cli::parse();
+ // Handle dynamic completions before entering the tokio runtime.
+ // Completers create their own single-threaded runtime to query the DB,
+ // which would panic if nested inside #[tokio::main].
+ clap_complete::CompleteEnv::with_factory(Cli::command).complete();
- // Handle completions before DB connection — no database needed
- if let Some(Commands::Completions { shell }) = &cli.command {
- let mut cmd = Cli::command();
- clap_complete::generate(*shell, &mut cmd, "ranger", &mut std::io::stdout());
- return Ok(());
- }
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()?
+ .block_on(async_main())
+}
+async fn async_main() -> color_eyre::Result<()> {
+ let cli = Cli::parse();
let db_path = resolve_db_path(cli.db);
let pool = ranger::db::connect(&db_path).await?;
@@ -105,7 +104,6 @@ async fn main() -> color_eyre::Result<()> {
Some(Commands::Tag { command }) => {
commands::tag::run(&pool, command, cli.json).await?;
}
- Some(Commands::Completions { .. }) => unreachable!(),
Some(Commands::Serve { port, backlog }) => {
commands::serve::run(&pool, port, backlog).await?;
}
diff --git a/tests/cli.rs b/tests/cli.rs
index 843980a..885dbfc 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -421,14 +421,41 @@ fn full_workflow() {
assert!(!stdout.contains("bug"));
assert!(stdout.contains("frontend"));
- // Shell completions (no DB needed, but pass one anyway for the helper)
+ // Dynamic shell completions via COMPLETE env var
for shell in ["bash", "zsh", "fish", "elvish", "powershell"] {
- let output = ranger(db_path)
- .args(["completions", shell])
- .output()
- .unwrap();
+ let output = ranger(db_path).env("COMPLETE", shell).output().unwrap();
assert!(output.status.success(), "completions failed for {shell}");
let stdout = String::from_utf8(output.stdout).unwrap();
- assert!(!stdout.is_empty(), "completions empty for {shell}");
+ assert!(
+ !stdout.is_empty(),
+ "completions registration empty for {shell}"
+ );
}
+
+ // Dynamic completion of task keys
+ let output = ranger(db_path)
+ .env("COMPLETE", "fish")
+ .args(["--", "ranger", "task", "show", ""])
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "task key completion failed");
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ // Should include task keys with help text showing [state] and title
+ assert!(
+ stdout.contains("First task"),
+ "task key completions should include task titles as help text, got: {stdout}"
+ );
+
+ // Dynamic completion of backlog names
+ let output = ranger(db_path)
+ .env("COMPLETE", "fish")
+ .args(["--", "ranger", "backlog", "show", ""])
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "backlog name completion failed");
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(
+ stdout.contains("Ranger"),
+ "backlog name completions should include backlog names"
+ );
}