Skip to content

Commit 2b85bd1

Browse files
committed
completion: fix completion of arguments for aliases/default-command in bash and zsh
This also adds a test case for the completion of arguments following multi-argument aliases, to cover the bug reported in issue #5377. The default command is like a special kind of alias, one which is expanded from zero tokens. Consequently, it also triggers the bug #5377 on bash/zsh, even if the `default-command` is just a single token. The fix is along the lines sketched out by the "TODO" comment. Bash and Zsh don't behave identical, so the padding ([""]) still needs to be applied (and removed) conditionally in a disciplined manner.
1 parent 81a47da commit 2b85bd1

File tree

5 files changed

+188
-22
lines changed

5 files changed

+188
-22
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7272
* The builtin diff editor now tries to preserve unresolved conflicts.
7373
[#4963](https://github.com/jj-vcs/jj/issues/4963)
7474

75+
* Fixed bash and zsh shell completion when completing aliases of multiple arguments.
76+
[#5377](https://github.com/jj-vcs/jj/issues/5377)
77+
7578
### Packaging changes
7679

7780
* Jujutsu now uses

cli/src/cli_util.rs

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3551,29 +3551,55 @@ fn handle_shell_completion(
35513551
config: &StackedConfig,
35523552
cwd: &Path,
35533553
) -> Result<(), CommandError> {
3554+
let mut orig_args = env::args_os();
3555+
35543556
let mut args = vec![];
35553557
// Take the first two arguments as is, they must be passed to clap_complete
35563558
// without any changes. They are usually "jj --".
3557-
args.extend(env::args_os().take(2));
3559+
args.extend(orig_args.by_ref().take(2));
35583560

35593561
// Make sure aliases are expanded before passing them to clap_complete. We
35603562
// skip the first two args ("jj" and "--") for alias resolution, then we
35613563
// stitch the args back together, like clap_complete expects them.
3562-
let orig_args = env::args_os().skip(2);
35633564
if orig_args.len() > 0 {
3564-
let arg_index: Option<usize> = env::var("_CLAP_COMPLETE_INDEX")
3565+
let complete_index: Option<usize> = env::var("_CLAP_COMPLETE_INDEX")
35653566
.ok()
35663567
.and_then(|s| s.parse().ok());
3567-
let resolved_aliases = if let Some(index) = arg_index {
3568+
let resolved_aliases = if let Some(index) = complete_index {
35683569
// As of clap_complete 4.5.38, zsh completion script doesn't pad an
35693570
// empty arg at the complete position. If the args doesn't include a
35703571
// command name, the default command would be expanded at that
35713572
// position. Therefore, no other command names would be suggested.
3572-
// TODO: Maybe we should instead expand args[..index] + [""], adjust
3573-
// the index accordingly, strip the last "", and append remainder?
35743573
let pad_len = usize::saturating_sub(index + 1, orig_args.len());
3575-
let padded_args = orig_args.chain(std::iter::repeat_n(OsString::new(), pad_len));
3576-
expand_args(ui, app, padded_args, config)?
3574+
let padded_args = orig_args
3575+
.by_ref()
3576+
.chain(std::iter::repeat_n(OsString::new(), pad_len));
3577+
3578+
// Expand aliases left of the completion index.
3579+
let mut expanded_args = expand_args(ui, app, padded_args.take(index + 1), config)?;
3580+
3581+
// Adjust env var to compensate for shift of the completion point in the
3582+
// expanded command line.
3583+
// SAFETY: Program is running single-threaded at this point.
3584+
unsafe {
3585+
env::set_var(
3586+
"_CLAP_COMPLETE_INDEX",
3587+
(expanded_args.len() - 1).to_string(),
3588+
);
3589+
}
3590+
3591+
// Remove extra padding again to align with clap_complete's expectations for
3592+
// zsh.
3593+
let split_off_padding = expanded_args.split_off(expanded_args.len() - pad_len);
3594+
assert!(
3595+
split_off_padding.iter().all(|s| s.is_empty()),
3596+
"split-off padding should only consist of empty strings but was \
3597+
{split_off_padding:?}",
3598+
);
3599+
3600+
// Append the remaining arguments to the right of the completion point.
3601+
expanded_args.extend(to_string_args(orig_args)?);
3602+
expanded_args
35773603
} else {
35783604
expand_args(ui, app, orig_args, config)?
35793605
};
@@ -3598,19 +3624,24 @@ pub fn expand_args(
35983624
args_os: impl IntoIterator<Item = OsString>,
35993625
config: &StackedConfig,
36003626
) -> Result<Vec<String>, CommandError> {
3601-
let mut string_args: Vec<String> = vec![];
3602-
for arg_os in args_os {
3603-
if let Some(string_arg) = arg_os.to_str() {
3604-
string_args.push(string_arg.to_owned());
3605-
} else {
3606-
return Err(cli_error("Non-utf8 argument"));
3607-
}
3608-
}
3609-
3627+
let string_args = to_string_args(args_os)?;
36103628
let string_args = resolve_default_command(ui, config, app, string_args)?;
36113629
resolve_aliases(ui, config, app, string_args)
36123630
}
36133631

3632+
fn to_string_args(
3633+
args_os: impl IntoIterator<Item = OsString>,
3634+
) -> Result<Vec<String>, CommandError> {
3635+
args_os
3636+
.into_iter()
3637+
.map(|arg_os| {
3638+
arg_os
3639+
.into_string()
3640+
.map_err(|_| cli_error("Non-UTF-8 argument"))
3641+
})
3642+
.collect()
3643+
}
3644+
36143645
fn parse_args(app: &Command, string_args: &[String]) -> Result<(ArgMatches, Args), clap::Error> {
36153646
let matches = app
36163647
.clone()

cli/tests/common/command_output.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ impl CommandOutput {
5757
}
5858
}
5959

60+
/// Removes all but the first `n` lines from normalized stdout text.
61+
#[must_use]
62+
pub fn take_stdout_n_lines(self, n: usize) -> Self {
63+
CommandOutput {
64+
stdout: self.stdout.take_n_lines(n),
65+
stderr: self.stderr,
66+
status: self.status,
67+
}
68+
}
69+
6070
#[must_use]
6171
pub fn normalize_stdout_with(self, f: impl FnOnce(String) -> String) -> Self {
6272
CommandOutput {
@@ -141,6 +151,12 @@ impl CommandOutputString {
141151
})
142152
}
143153

154+
/// Removes all but the first `n` lines from the normalized text.
155+
#[must_use]
156+
pub fn take_n_lines(self, n: usize) -> Self {
157+
self.normalize_with(|s| s.split_inclusive("\n").take(n).collect())
158+
}
159+
144160
#[must_use]
145161
pub fn normalize_with(mut self, f: impl FnOnce(String) -> String) -> Self {
146162
self.normalized = f(self.normalized);

cli/tests/test_completion.rs

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ fn test_aliases_are_resolved(shell: Shell) {
321321

322322
// user config alias
323323
test_env.add_config(r#"aliases.b = ["bookmark"]"#);
324+
test_env.add_config(r#"aliases.rlog = ["log", "--reversed"]"#);
324325
// repo config alias
325326
work_dir
326327
.run_jj(["config", "set", "--repo", "aliases.b2", "['bookmark']"])
@@ -359,6 +360,30 @@ fn test_aliases_are_resolved(shell: Shell) {
359360
}
360361
_ => unimplemented!("unexpected shell '{shell}'"),
361362
}
363+
364+
let output = work_dir.complete_at(shell, 2, ["rlog", "--rev"]);
365+
match shell {
366+
Shell::Bash => {
367+
insta::assert_snapshot!(output, @r"
368+
--revisions
369+
--reversed[EOF]
370+
");
371+
}
372+
Shell::Zsh => {
373+
insta::assert_snapshot!(output, @r"
374+
--revisions:Which revisions to show
375+
--reversed:Show revisions in the opposite order (older revisions first)[EOF]
376+
");
377+
}
378+
Shell::Fish => {
379+
insta::assert_snapshot!(output, @r"
380+
--revisions Which revisions to show
381+
--reversed Show revisions in the opposite order (older revisions first)
382+
[EOF]
383+
");
384+
}
385+
_ => unimplemented!("unexpected shell '{shell}'"),
386+
}
362387
}
363388

364389
#[test]
@@ -381,6 +406,99 @@ fn test_completions_are_generated() {
381406
");
382407
}
383408

409+
#[test_case(Shell::Bash; "bash")]
410+
#[test_case(Shell::Zsh; "zsh")]
411+
#[test_case(Shell::Fish; "fish")]
412+
fn test_default_command_is_resolved(shell: Shell) {
413+
let test_env = TestEnvironment::default();
414+
415+
let output = test_env
416+
.complete_at(shell, 1, ["--"])
417+
.take_stdout_n_lines(2);
418+
match shell {
419+
Shell::Bash => {
420+
insta::assert_snapshot!(output, @r"
421+
--revisions
422+
--limit
423+
[EOF]
424+
");
425+
}
426+
Shell::Zsh => {
427+
insta::assert_snapshot!(output, @r"
428+
--revisions:Which revisions to show
429+
--limit:Limit number of revisions to show
430+
[EOF]
431+
");
432+
}
433+
Shell::Fish => {
434+
insta::assert_snapshot!(output, @r"
435+
--revisions Which revisions to show
436+
--limit Limit number of revisions to show
437+
[EOF]
438+
");
439+
}
440+
_ => unimplemented!("unexpected shell '{shell}'"),
441+
}
442+
443+
test_env.add_config("ui.default-command = ['abandon']");
444+
let output = test_env
445+
.complete_at(shell, 1, ["--"])
446+
.take_stdout_n_lines(2);
447+
match shell {
448+
Shell::Bash => {
449+
insta::assert_snapshot!(output, @r"
450+
--retain-bookmarks
451+
--restore-descendants
452+
[EOF]
453+
");
454+
}
455+
Shell::Zsh => {
456+
insta::assert_snapshot!(output, @r"
457+
--retain-bookmarks:Do not delete bookmarks pointing to the revisions to abandon
458+
--restore-descendants:Do not modify the content of the children of the abandoned commits
459+
[EOF]
460+
");
461+
}
462+
Shell::Fish => {
463+
insta::assert_snapshot!(output, @r"
464+
--retain-bookmarks Do not delete bookmarks pointing to the revisions to abandon
465+
--restore-descendants Do not modify the content of the children of the abandoned commits
466+
[EOF]
467+
");
468+
}
469+
_ => unimplemented!("unexpected shell '{shell}'"),
470+
}
471+
472+
test_env.add_config("ui.default-command = ['bookmark', 'move']");
473+
let output = test_env
474+
.complete_at(shell, 1, ["--"])
475+
.take_stdout_n_lines(2);
476+
match shell {
477+
Shell::Bash => {
478+
insta::assert_snapshot!(output, @r"
479+
--from
480+
--to
481+
[EOF]
482+
");
483+
}
484+
Shell::Zsh => {
485+
insta::assert_snapshot!(output, @r"
486+
--from:Move bookmarks from the given revisions
487+
--to:Move bookmarks to this revision
488+
[EOF]
489+
");
490+
}
491+
Shell::Fish => {
492+
insta::assert_snapshot!(output, @r"
493+
--from Move bookmarks from the given revisions
494+
--to Move bookmarks to this revision
495+
[EOF]
496+
");
497+
}
498+
_ => unimplemented!("unexpected shell '{shell}'"),
499+
}
500+
}
501+
384502
#[test_case(Shell::Bash; "bash")]
385503
#[test_case(Shell::Zsh; "zsh")]
386504
#[test_case(Shell::Fish; "fish")]
@@ -389,9 +507,7 @@ fn test_command_completion(shell: Shell) {
389507

390508
// Command names should be suggested. If the default command were expanded,
391509
// only "log" would be listed.
392-
let output = test_env
393-
.complete_at(shell, 1, [""])
394-
.normalize_stdout_with(|s| s.split_inclusive('\n').take(2).collect());
510+
let output = test_env.complete_at(shell, 1, [""]).take_stdout_n_lines(2);
395511
match shell {
396512
Shell::Bash => {
397513
insta::assert_snapshot!(output, @r"
@@ -419,7 +535,7 @@ fn test_command_completion(shell: Shell) {
419535

420536
let output = test_env
421537
.complete_at(shell, 2, ["--no-pager", ""])
422-
.normalize_stdout_with(|s| s.split_inclusive('\n').take(2).collect());
538+
.take_stdout_n_lines(2);
423539
match shell {
424540
Shell::Bash => {
425541
insta::assert_snapshot!(output, @r"

cli/tests/test_global_opts.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ fn test_non_utf8_arg() {
3737
let output = test_env.run_jj_in(".", [&invalid_utf]);
3838
insta::assert_snapshot!(output, @r"
3939
------- stderr -------
40-
Error: Non-utf8 argument
40+
Error: Non-UTF-8 argument
4141
[EOF]
4242
[exit status: 2]
4343
");

0 commit comments

Comments
 (0)