Skip to content

Commit 76e8b73

Browse files
committed
cli: add support for jj help alias
i consider rewriting the `help` command, but that breaks some other things (e.g. tab completion doesn't work, because `clap_complete` doesn't know about the aliases). instead, i found that i can add each alias to the `clap::Command` dynamically at runtime. this works! the help it shows is useful and easy to modify, and it's only about 25 lines of code. the problem is this breaks `resolve_aliases` pretty badly. the replacement loop uses *clap's* API to determine the arguments that come after the alias, and clap does not expose a way to get the raw arguments, only the parsed arguments. it happened to work before because the commands were all `External`, in which case clap doesn't do any parsing. i tried to just stop using clap's API, which simplifies the code a lot and works with the new dynamically added alias subcommands. but that breaks global flags that precede the alias, things like `jj --no-pager alias`. instead, i waited to add the aliases to `Command` until after replacing the string arguments. that breaks because `expand_args()` gets called twice, once before and once after processing `--repository`. so even if i do this afterwards in the `resolve_aliases` function, the *next* call will still break because it parses differently. to work around *that*, i clone the app before passing it to `expand_args`, so that the modifications don't affect the --repository parsing. it's kind of hacky, but i haven't found a simpler solution. --- ## future work * `jj alias -h` shows the help for the *resolved* alias, not the "Alias for ..." short help. that happens because the alias is replaced with its definition before passing it to get_matches. it would be nice to make it consistent with `help`. * `jj help` without arguments shows builtin aliases in a different format than user-defined aliases (builtins look like `[aliases: st]` in the short help). i tried to make these consistent, but [ran into trouble](clap-rs/clap#5497) that i'm not sure is fixable.
1 parent 0d3e949 commit 76e8b73

File tree

5 files changed

+210
-30
lines changed

5 files changed

+210
-30
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
* `jj split` will now refuse to split an empty commit.
1919

20+
* TOML type errors in alias definitions are now reported eagerly. Previously, they would not error unless you used the alias in a jj command.
21+
2022
### Deprecations
2123

2224
- Attempting to alias a built-in command now gives a warning, rather than being silently ignored.
@@ -33,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3335

3436
* `jj branch track` now show conflicts if there are some.
3537

38+
* `jj help` and `jj util completion` now support aliases.
39+
40+
3641
### Fixed bugs
3742

3843
* When the working copy commit becomes immutable, a new one is automatically created on top of it

Cargo.lock

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ regex = { workspace = true }
7474
rpassword = { workspace = true }
7575
scm-record = { workspace = true }
7676
serde = { workspace = true }
77+
shlex = "1.3.0"
7778
slab = { workspace = true }
7879
strsim = { workspace = true }
7980
tempfile = { workspace = true }

cli/src/cli_util.rs

+105-28
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
use core::fmt;
1616
use std::borrow::Cow;
17-
use std::collections::{BTreeMap, HashSet};
17+
use std::collections::{BTreeMap, HashMap, HashSet};
1818
use std::env::{self, ArgsOs, VarError};
1919
use std::ffi::OsString;
2020
use std::fmt::Debug;
@@ -2599,12 +2599,16 @@ fn resolve_default_command(
25992599
Ok(string_args)
26002600
}
26012601

2602-
fn resolve_aliases(
2602+
type AliasMap = HashMap<String, Vec<String>>;
2603+
2604+
// NOTE: logging doesn't work in this function because we don't enable it until
2605+
// after expanding arguments.
2606+
fn expand_aliases(
26032607
ui: &Ui,
26042608
config: &config::Config,
26052609
app: &Command,
26062610
mut string_args: Vec<String>,
2607-
) -> Result<Vec<String>, CommandError> {
2611+
) -> Result<(Vec<String>, AliasMap), CommandError> {
26082612
let mut aliases_map = config.get_table("aliases")?;
26092613
if let Ok(alias_map) = config.get_table("alias") {
26102614
for (alias, definition) in alias_map {
@@ -2618,21 +2622,31 @@ fn resolve_aliases(
26182622
}
26192623
}
26202624

2621-
let mut resolved_aliases = HashSet::new();
2625+
let mut expanded_aliases = HashSet::new();
26222626
let mut real_commands = HashSet::new();
26232627
for command in app.get_subcommands() {
26242628
real_commands.insert(command.get_name().to_string());
26252629
for alias in command.get_all_aliases() {
26262630
real_commands.insert(alias.to_string());
26272631
}
26282632
}
2629-
for alias in aliases_map.keys() {
2630-
if real_commands.contains(alias) {
2633+
2634+
let mut resolved_alias_map = HashMap::new();
2635+
for (alias_name, value) in aliases_map {
2636+
if real_commands.contains(&alias_name) {
26312637
writeln!(
26322638
ui.warning_default(),
2633-
"Cannot define an alias that overrides the built-in command '{alias}'"
2639+
"Cannot define an alias that overrides the built-in command '{alias_name}'"
26342640
)?;
2641+
continue;
26352642
}
2643+
2644+
let Ok(alias_definition) = value.try_deserialize::<Vec<String>>() else {
2645+
return Err(user_error(format!(
2646+
r#"Alias definition for "{alias_name}" must be a string list"#
2647+
)));
2648+
};
2649+
resolved_alias_map.insert(alias_name, alias_definition);
26362650
}
26372651

26382652
loop {
@@ -2646,32 +2660,89 @@ fn resolve_aliases(
26462660
.unwrap_or_default()
26472661
.map(|arg| arg.to_str().unwrap().to_string())
26482662
.collect_vec();
2649-
if resolved_aliases.contains(&alias_name) {
2663+
if expanded_aliases.contains(&alias_name) {
26502664
return Err(user_error(format!(
26512665
r#"Recursive alias definition involving "{alias_name}""#
26522666
)));
26532667
}
2654-
if let Some(value) = aliases_map.remove(&alias_name) {
2655-
if let Ok(alias_definition) = value.try_deserialize::<Vec<String>>() {
2656-
assert!(string_args.ends_with(&alias_args));
2657-
string_args.truncate(string_args.len() - 1 - alias_args.len());
2658-
string_args.extend(alias_definition);
2659-
string_args.extend_from_slice(&alias_args);
2660-
resolved_aliases.insert(alias_name.clone());
2661-
continue;
2662-
} else {
2663-
return Err(user_error(format!(
2664-
r#"Alias definition for "{alias_name}" must be a string list"#
2665-
)));
2666-
}
2668+
if let Some(alias_definition) = resolved_alias_map.get(&alias_name) {
2669+
assert!(string_args.ends_with(&alias_args));
2670+
string_args.truncate(string_args.len() - 1 - alias_args.len());
2671+
string_args.extend(alias_definition.clone());
2672+
string_args.extend_from_slice(&alias_args);
2673+
expanded_aliases.insert(alias_name.clone());
2674+
continue;
26672675
} else {
26682676
// Not a real command and not an alias, so return what we've resolved so far
2669-
return Ok(string_args);
2677+
break;
26702678
}
26712679
}
26722680
}
26732681
// No more alias commands, or hit unknown option
2674-
return Ok(string_args);
2682+
break;
2683+
}
2684+
2685+
Ok((string_args, resolved_alias_map))
2686+
}
2687+
2688+
fn add_alias_help(app: &mut Command, mut aliases: AliasMap) {
2689+
let render_definition = |def: &[String]| match shlex::try_join(def.into_iter().map(|s| &**s)) {
2690+
Ok(s) => format!("Alias for \"{s}\""),
2691+
Err(_) => format!("Alias for {def:?}"),
2692+
};
2693+
2694+
// Add each alias to `Command` so it shows up in help
2695+
// Aliases may be defined in terms of other aliases, and it's allowed for them
2696+
// to be defined in any order in the TOML config. Repeat this algorithm
2697+
// until we no longer make progress.
2698+
let mut progress;
2699+
let mut try_again_later = HashMap::new();
2700+
loop {
2701+
progress = false;
2702+
for (alias_name, alias_definition) in aliases.drain() {
2703+
// Find the innermost subcommand so we can use its help.
2704+
let mut subcmd = None;
2705+
for arg in &alias_definition {
2706+
if let Some(cmd) = subcmd.unwrap_or(&*app).find_subcommand(arg) {
2707+
subcmd = Some(cmd);
2708+
} else {
2709+
break;
2710+
}
2711+
}
2712+
let Some(subcmd) = subcmd else {
2713+
// We never found a valid subcommand. Skip this iteration; maybe it's an alias
2714+
// to another alias.
2715+
try_again_later.insert(alias_name, alias_definition);
2716+
continue;
2717+
};
2718+
progress = true;
2719+
let help = render_definition(&alias_definition);
2720+
let subcmd_alias = subcmd
2721+
.clone()
2722+
.name(alias_name)
2723+
// if we leave the built-in aliases, clap doesn't know whether to call the user
2724+
// alias or the built-in command
2725+
.alias(None)
2726+
.before_help(&help)
2727+
.before_long_help(help);
2728+
*app = std::mem::take(app).subcommand(subcmd_alias);
2729+
}
2730+
if !progress {
2731+
break;
2732+
}
2733+
aliases.extend(try_again_later.drain());
2734+
}
2735+
// We may still have some unhandled aliases. That means there's an alias with
2736+
// valid toml syntax, but points to a subcommand that doesn't exist. Add
2737+
// help for that too.
2738+
for (name, definition) in try_again_later {
2739+
let help = render_definition(&definition);
2740+
// TODO: we can't distinguish empty/option-only aliases from aliases without a
2741+
// subcommand :( This leaves the default "global options" help in case
2742+
// it does happen to be valid. Perhaps we should do
2743+
// `app.try_parse_matches` here so we can distinguish the two?
2744+
let subcmd = Command::new(name).about(help);
2745+
*app = std::mem::take(app).subcommand(subcmd);
26752746
}
26762747
}
26772748

@@ -2713,7 +2784,7 @@ pub fn expand_args(
27132784
app: &Command,
27142785
args_os: ArgsOs,
27152786
config: &config::Config,
2716-
) -> Result<Vec<String>, CommandError> {
2787+
) -> Result<(Vec<String>, AliasMap), CommandError> {
27172788
let mut string_args: Vec<String> = vec![];
27182789
for arg_os in args_os {
27192790
if let Some(string_arg) = arg_os.to_str() {
@@ -2724,7 +2795,7 @@ pub fn expand_args(
27242795
}
27252796

27262797
let string_args = resolve_default_command(ui, config, app, string_args)?;
2727-
resolve_aliases(ui, config, app, string_args)
2798+
expand_aliases(ui, config, app, string_args)
27282799
}
27292800

27302801
pub fn parse_args(
@@ -2903,7 +2974,7 @@ impl CliRunner {
29032974

29042975
#[instrument(skip_all)]
29052976
fn run_internal(
2906-
self,
2977+
mut self,
29072978
ui: &mut Ui,
29082979
mut layered_configs: LayeredConfigs,
29092980
) -> Result<(), CommandError> {
@@ -2929,7 +3000,11 @@ impl CliRunner {
29293000
let config = layered_configs.merge();
29303001
ui.reset(&config)?;
29313002

2932-
let string_args = expand_args(ui, &self.app, env::args_os(), &config)?;
3003+
// save this - since `expand_args` modifies app, it won't parse the same when we
3004+
// process -R
3005+
let original_app = self.app.clone();
3006+
let (string_args, aliases) = expand_args(ui, &self.app, env::args_os(), &config)?;
3007+
add_alias_help(&mut self.app, aliases);
29333008
let (matches, args) = parse_args(
29343009
ui,
29353010
&self.app,
@@ -2959,7 +3034,9 @@ impl CliRunner {
29593034
// If -R is specified, check if the expanded arguments differ. Aliases
29603035
// can also be injected by --config-toml, but that's obviously wrong.
29613036
if args.global_args.repository.is_some() {
2962-
let new_string_args = expand_args(ui, &self.app, env::args_os(), &config).ok();
3037+
let new_string_args = expand_args(ui, &original_app, env::args_os(), &config)
3038+
.ok()
3039+
.map(|(args, _)| args);
29633040
if new_string_args.as_ref() != Some(&string_args) {
29643041
writeln!(
29653042
ui.warning_default(),

cli/tests/test_alias.rs

+92-2
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,22 @@ fn test_alias_global_args_in_definition() {
249249
#[test]
250250
fn test_alias_invalid_definition() {
251251
let test_env = TestEnvironment::default();
252-
253252
test_env.add_config(
254253
r#"[aliases]
255254
non-list = 5
256-
non-string-list = [[]]
257255
"#,
258256
);
259257
let stderr = test_env.jj_cmd_failure(test_env.env_root(), &["non-list"]);
260258
insta::assert_snapshot!(stderr, @r###"
261259
Error: Alias definition for "non-list" must be a string list
262260
"###);
261+
262+
let test_env = TestEnvironment::default();
263+
test_env.add_config(
264+
r#"[aliases]
265+
non-string-list = [[]]
266+
"#,
267+
);
263268
let stderr = test_env.jj_cmd_failure(test_env.env_root(), &["non-string-list"]);
264269
insta::assert_snapshot!(stderr, @r###"
265270
Error: Alias definition for "non-string-list" must be a string list
@@ -337,3 +342,88 @@ fn test_alias_in_repo_config() {
337342
aliases.l=["log", "-r@", "--no-graph", "-T\"user alias\\n\""]
338343
"###);
339344
}
345+
346+
#[test]
347+
fn test_alias_help() {
348+
let test_env = TestEnvironment::default();
349+
test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]);
350+
let repo_path = test_env.env_root().join("repo");
351+
352+
test_env.add_config(r#"aliases.b = ["log", "-r", "@", "-T", "branches"]"#);
353+
test_env.add_config(r#"aliases.s = ["status"]"#);
354+
test_env.add_config(r#"aliases.f = ["git", "fetch"]"#); // test nested subcommand
355+
test_env.add_config(r#"aliases.b2 = ["b", "--no-graph"]"#); // test recursive subcommand
356+
test_env.add_config(r#"aliases.empty = []"#);
357+
test_env.add_config(r#"aliases.option-only = ["--no-pager"]"#);
358+
test_env.add_config(r#"aliases.bad = ["this-command-does-not-exist"]"#);
359+
360+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "b"]);
361+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
362+
Alias for "log -r @ -T branches"
363+
364+
Show revision history
365+
"###);
366+
let stdout = test_env.jj_cmd_success(&repo_path, &["b", "-h"]);
367+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Show revision history");
368+
369+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "s"]);
370+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
371+
Alias for "status"
372+
373+
Show high-level repo status
374+
"###);
375+
let stdout = test_env.jj_cmd_success(&repo_path, &["s", "-h"]);
376+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Show high-level repo status");
377+
378+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "f"]);
379+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
380+
Alias for "git fetch"
381+
382+
Fetch from a Git remote
383+
"###);
384+
let stdout = test_env.jj_cmd_success(&repo_path, &["f", "-h"]);
385+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Fetch from a Git remote");
386+
387+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "b2"]);
388+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
389+
Alias for "b --no-graph"
390+
391+
Show revision history
392+
"###);
393+
let stdout = test_env.jj_cmd_success(&repo_path, &["b2", "-h"]);
394+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Show revision history");
395+
396+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "empty"]);
397+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
398+
Alias for ""
399+
400+
Usage: jj empty [OPTIONS]
401+
"###);
402+
let stdout = test_env.jj_cmd_success(&repo_path, &["empty", "-h"]);
403+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Jujutsu (An experimental VCS)");
404+
405+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "option-only"]);
406+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
407+
Alias for "--no-pager"
408+
409+
Usage: jj option-only [OPTIONS]
410+
"###);
411+
let stdout = test_env.jj_cmd_success(&repo_path, &["option-only", "-h"]);
412+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Jujutsu (An experimental VCS)");
413+
414+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "bad"]);
415+
// TODO: this isn't ideal, see the comment in `resolve_aliases`
416+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
417+
Alias for "this-command-does-not-exist"
418+
419+
Usage: jj bad [OPTIONS]
420+
"###);
421+
let stderr = test_env.jj_cmd_cli_error(&repo_path, &["bad", "-h"]);
422+
insta::assert_snapshot!(stderr, @r###"
423+
error: unrecognized subcommand 'this-command-does-not-exist'
424+
425+
Usage: jj [OPTIONS] <COMMAND>
426+
427+
For more information, try '--help'.
428+
"###);
429+
}

0 commit comments

Comments
 (0)