From c9e751ae6e26cdd3d7ecb9ba62a25884ceeb5024 Mon Sep 17 00:00:00 2001 From: Remo Senekowitsch Date: Wed, 30 Oct 2024 12:41:19 +0100 Subject: [PATCH] completion: teach rename about local bookmarks --- CHANGELOG.md | 4 + Cargo.lock | 12 +++ Cargo.toml | 2 +- cli/src/cli_util.rs | 15 +++- cli/src/commands/bookmark/rename.rs | 3 + cli/src/complete.rs | 126 ++++++++++++++++++++++++++++ cli/src/lib.rs | 1 + cli/tests/runner.rs | 1 + cli/tests/test_completion.rs | 78 +++++++++++++++++ docs/install-and-setup.md | 29 +++++++ 10 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 cli/src/complete.rs create mode 100644 cli/tests/test_completion.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a10893c0d..632c4a224e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * New command `jj util exec` that can be used for arbitrary aliases. +* A preview of improved shell completions was added. Please refer to the + [documentation](https://martinvonz.github.io/jj/latest/install-and-setup/#command-line-completion) + to activate them. + ### Fixed bugs ## [0.23.0] - 2024-11-06 diff --git a/Cargo.lock b/Cargo.lock index 1bee084083..7ae2979145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,6 +394,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11611dca53440593f38e6b25ec629de50b14cdfa63adc0fb856115a2c6d97595" dependencies = [ "clap", + "clap_lex", + "is_executable", + "shlex", ] [[package]] @@ -1754,6 +1757,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_executable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a1b5bad6f9072935961dfbf1cced2f3d129963d091b6f69f007fe04e758ae2" +dependencies = [ + "winapi", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" diff --git a/Cargo.toml b/Cargo.toml index 15aa19b2e7..1a912b5e83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ clap = { version = "4.5.20", features = [ "wrap_help", "string", ] } -clap_complete = "4.5.37" +clap_complete = { version = "4.5.37", features = ["unstable-dynamic"] } clap_complete_nushell = "4.5.4" clap-markdown = "0.1.4" clap_mangen = "0.2.10" diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 4a2d328670..b5ef0c417e 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -17,7 +17,6 @@ use std::cell::OnceCell; use std::collections::BTreeMap; use std::collections::HashSet; use std::env; -use std::env::ArgsOs; use std::env::VarError; use std::ffi::OsString; use std::fmt; @@ -2128,7 +2127,7 @@ impl WorkspaceCommandTransaction<'_> { } } -fn find_workspace_dir(cwd: &Path) -> &Path { +pub fn find_workspace_dir(cwd: &Path) -> &Path { cwd.ancestors() .find(|path| path.join(".jj").is_dir()) .unwrap_or(cwd) @@ -3057,7 +3056,7 @@ fn handle_early_args( pub fn expand_args( ui: &Ui, app: &Command, - args_os: ArgsOs, + args_os: impl IntoIterator, config: &config::Config, ) -> Result, CommandError> { let mut string_args: Vec = vec![]; @@ -3379,6 +3378,16 @@ impl CliRunner { #[must_use] #[instrument(skip(self))] pub fn run(mut self) -> ExitCode { + match clap_complete::CompleteEnv::with_factory(|| self.app.clone()) + .try_complete(env::args_os(), None) + { + Ok(true) => return ExitCode::SUCCESS, + Err(e) => { + eprintln!("failed to generate completions: {e}"); + return ExitCode::FAILURE; + } + Ok(false) => {} + }; let builder = config::Config::builder().add_source(crate::config::default_config()); let config = self .extra_configs diff --git a/cli/src/commands/bookmark/rename.rs b/cli/src/commands/bookmark/rename.rs index f5b2a525a1..e11cf99068 100644 --- a/cli/src/commands/bookmark/rename.rs +++ b/cli/src/commands/bookmark/rename.rs @@ -12,12 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +use clap_complete::ArgValueCandidates; use jj_lib::op_store::RefTarget; use super::has_tracked_remote_bookmarks; use crate::cli_util::CommandHelper; use crate::command_error::user_error; use crate::command_error::CommandError; +use crate::complete; use crate::ui::Ui; /// Rename `old` bookmark name to `new` bookmark name @@ -26,6 +28,7 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub struct BookmarkRenameArgs { /// The old name of the bookmark + #[arg(add = ArgValueCandidates::new(complete::local_bookmarks))] old: String, /// The new name of the bookmark diff --git a/cli/src/complete.rs b/cli/src/complete.rs new file mode 100644 index 0000000000..681441f6dd --- /dev/null +++ b/cli/src/complete.rs @@ -0,0 +1,126 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use clap::FromArgMatches as _; +use clap_complete::CompletionCandidate; +use jj_lib::workspace::DefaultWorkspaceLoaderFactory; +use jj_lib::workspace::WorkspaceLoaderFactory as _; + +use crate::cli_util::expand_args; +use crate::cli_util::find_workspace_dir; +use crate::cli_util::GlobalArgs; +use crate::command_error::user_error; +use crate::command_error::CommandError; +use crate::config::default_config; +use crate::config::LayeredConfigs; +use crate::ui::Ui; + +pub fn local_bookmarks() -> Vec { + with_jj(|mut jj| { + let output = jj + .arg("bookmark") + .arg("list") + .arg("--template") + .arg(r#"if(!remote, name ++ "\n")"#) + .output() + .map_err(user_error)?; + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(CompletionCandidate::new) + .collect()) + }) +} + +/// Shell out to jj during dynamic completion generation +/// +/// In case of errors, print them and early return an empty vector. +fn with_jj(completion_fn: F) -> Vec +where + F: FnOnce(std::process::Command) -> Result, CommandError>, +{ + get_jj_command() + .and_then(completion_fn) + .unwrap_or_else(|e| { + eprintln!("{}", e.error); + Vec::new() + }) +} + +/// Shell out to jj during dynamic completion generation +/// +/// This is necessary because dynamic completion code needs to be aware of +/// global configuration like custom storage backends. Dynamic completion +/// code via clap_complete doesn't accept arguments, so they cannot be passed +/// that way. Another solution would've been to use global mutable state, to +/// give completion code access to custom backends. Shelling out was chosen as +/// the preferred method, because it's more maintainable and the performance +/// requirements of completions aren't very high. +fn get_jj_command() -> Result { + let current_exe = std::env::current_exe().map_err(user_error)?; + let mut command = std::process::Command::new(current_exe); + + // Snapshotting could make completions much slower in some situations + // and be undesired by the user. + command.arg("--ignore-working-copy"); + command.arg("--color=never"); + command.arg("--no-pager"); + + // Parse some of the global args we care about for passing along to the + // child process. This shouldn't fail, since none of the global args are + // required. + let app = crate::commands::default_app(); + let config = config::Config::builder() + .add_source(default_config()) + .build() + .expect("default config should be valid"); + let mut layered_configs = LayeredConfigs::from_environment(config); + let ui = Ui::with_config(&layered_configs.merge()).expect("default config should be valid"); + let cwd = std::env::current_dir() + .and_then(|cwd| cwd.canonicalize()) + .map_err(user_error)?; + let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd)); + layered_configs.read_user_config().map_err(user_error)?; + if let Ok(loader) = &maybe_cwd_workspace_loader { + layered_configs + .read_repo_config(loader.repo_path()) + .map_err(user_error)?; + } + let config = layered_configs.merge(); + // skip 2 because of the clap_complete prelude: jj -- jj + let args = std::env::args_os().skip(2); + let args = expand_args(&ui, &app, args, &config)?; + let args = app + .clone() + .disable_version_flag(true) + .disable_help_flag(true) + .ignore_errors(true) + .try_get_matches_from(args)?; + let args: GlobalArgs = GlobalArgs::from_arg_matches(&args)?; + + if let Some(repository) = args.repository { + command.arg("--repository"); + command.arg(repository); + } + if let Some(at_operation) = args.at_operation { + command.arg("--at-operation"); + command.arg(at_operation); + } + for config_toml in args.early_args.config_toml { + command.arg("--config-toml"); + command.arg(config_toml); + } + + Ok(command) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 20004e3ff5..dd8092d3c0 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -19,6 +19,7 @@ pub mod cli_util; pub mod command_error; pub mod commands; pub mod commit_templater; +pub mod complete; pub mod config; pub mod description_util; pub mod diff_util; diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 39a1c77b0d..2fa6d904b9 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -18,6 +18,7 @@ mod test_builtin_aliases; mod test_checkout; mod test_commit_command; mod test_commit_template; +mod test_completion; mod test_concurrent_operations; mod test_config_command; mod test_copy_detection; diff --git a/cli/tests/test_completion.rs b/cli/tests/test_completion.rs new file mode 100644 index 0000000000..7a268353a7 --- /dev/null +++ b/cli/tests/test_completion.rs @@ -0,0 +1,78 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::common::TestEnvironment; + +#[test] +fn test_bookmark_rename() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "aaa"]); + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "bbb"]); + + let mut test_env = test_env; + // Every shell hook is a little different, e.g. the zsh hooks add some + // additional environment variables. But this is irrelevant for the purpose + // of testing our own logic, so it's fine to test a single shell only. + test_env.add_env_var("COMPLETE", "fish"); + let test_env = test_env; + + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "bookmark", "rename", ""]); + insta::assert_snapshot!(stdout, @r" + aaa + bbb + --repository Path to repository to operate on + --ignore-working-copy Don't snapshot the working copy, and don't update it + --ignore-immutable Allow rewriting immutable commits + --at-operation Operation to load the repo at + --debug Enable debug logging + --color When to colorize output (always, never, debug, auto) + --quiet Silence non-primary command output + --no-pager Disable the pager + --config-toml Additional configuration options (can be repeated) + --help Print help (see more with '--help') + "); + + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "bookmark", "rename", "a"]); + insta::assert_snapshot!(stdout, @"aaa"); +} + +#[test] +fn test_global_arg_repository_is_respected() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "aaa"]); + + let mut test_env = test_env; + test_env.add_env_var("COMPLETE", "fish"); + let test_env = test_env; + + let stdout = test_env.jj_cmd_success( + test_env.env_root(), + &[ + "--", + "jj", + "--repository", + "repo", + "bookmark", + "rename", + "a", + ], + ); + insta::assert_snapshot!(stdout, @"aaa"); +} diff --git a/docs/install-and-setup.md b/docs/install-and-setup.md index 22011b014d..665cc8a995 100644 --- a/docs/install-and-setup.md +++ b/docs/install-and-setup.md @@ -210,12 +210,25 @@ To set up command-line completion, source the output of `jj util completion bash/zsh/fish`. Exactly how to source it depends on your shell. +Improved completions are currently in the works, these will complete things +like bookmark names as well. You can activate them with the alternative "dynamic" +instructions below. Please let us know if you encounter any issues, so we can +ensure a smooth transition once we default to these new completions. If you +have ideas about specific completions that could be added, please share them +[here](https://github.com/martinvonz/jj/issues/4763). + ### Bash ```shell source <(jj util completion bash) ``` +dynamic: + +```shell +echo "source <(COMPLETE=bash jj)" >> ~/.bashrc +``` + ### Zsh ```shell @@ -224,12 +237,24 @@ compinit source <(jj util completion zsh) ``` +dynamic: + +```shell +echo "source <(COMPLETE=zsh jj)" >> ~/.zshrc +``` + ### Fish ```shell jj util completion fish | source ``` +dynamic: + +```shell +echo "source (COMPLETE=fish jj | psub)" >> ~/.config/fish/config.fish +``` + ### Nushell ```nu @@ -237,8 +262,12 @@ jj util completion nushell | save completions-jj.nu use completions-jj.nu * # Or `source completions-jj.nu` ``` +(dynamic completions not available yet) + ### Xonsh ```shell source-bash $(jj util completion) ``` + +(dynamic completions not available yet)