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
change zyovnooqpuqvsorptmyqowykpywmwnzy
commit 1be1549c283eb3ebe490b9b5d7ebc19a906ef84b
author Alpha Chen <alpha@kejadlen.dev>
date
parent wzsmlwuz
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"
+    );
 }