Skip to content

Commit 5070a4c

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. --- this does have the downside that `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.
1 parent cc3c4c1 commit 5070a4c

File tree

3 files changed

+183
-26
lines changed

3 files changed

+183
-26
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
* Attempting to alias a built-in command now gives a hard error, rather than being silently ignored.
1717

18+
* Errors in alias definitions are now reported eagerly. Previously, they would not error unless you used the alias in a jj command.
19+
1820
### New features
1921

2022
* Templates now support logical operators: `||`, `&&`, `!`
2123

24+
* `jj help` and `jj util completion` now support aliases.
25+
2226
### Fixed bugs
2327

2428
* On Windows, symlinks in the repo are now materialized as regular files in the

cli/src/cli_util.rs

+87-24
Original file line numberDiff line numberDiff line change
@@ -2682,11 +2682,14 @@ fn resolve_default_command(
26822682
Ok(string_args)
26832683
}
26842684

2685-
fn resolve_aliases(
2685+
type AliasMap = HashMap<String, Vec<String>>;
2686+
2687+
// NOTE: logging doesn't work in this function because we don't enable it until after expanding arguments.
2688+
fn expand_aliases(
26862689
config: &config::Config,
26872690
app: &Command,
26882691
mut string_args: Vec<String>,
2689-
) -> Result<Vec<String>, CommandError> {
2692+
) -> Result<(Vec<String>, AliasMap), CommandError> {
26902693
let mut aliases_map = config.get_table("aliases")?;
26912694
if let Ok(alias_map) = config.get_table("alias") {
26922695
for (alias, definition) in alias_map {
@@ -2700,7 +2703,7 @@ fn resolve_aliases(
27002703
}
27012704
}
27022705

2703-
let mut resolved_aliases = HashSet::new();
2706+
let mut expanded_aliases = HashSet::new();
27042707
let mut real_commands = HashSet::new();
27052708
for command in app.get_subcommands() {
27062709
real_commands.insert(command.get_name().to_string());
@@ -2716,6 +2719,16 @@ fn resolve_aliases(
27162719
}
27172720
}
27182721

2722+
let mut resolved_alias_map = HashMap::new();
2723+
for (alias_name, value) in aliases_map {
2724+
let Ok(alias_definition) = value.try_deserialize::<Vec<String>>() else {
2725+
return Err(user_error(format!(
2726+
r#"Alias definition for "{alias_name}" must be a string list"#
2727+
)));
2728+
};
2729+
resolved_alias_map.insert(alias_name, alias_definition);
2730+
}
2731+
27192732
loop {
27202733
let app_clone = app.clone().allow_external_subcommands(true);
27212734
let matches = app_clone.try_get_matches_from(&string_args).ok();
@@ -2727,32 +2740,78 @@ fn resolve_aliases(
27272740
.unwrap_or_default()
27282741
.map(|arg| arg.to_str().unwrap().to_string())
27292742
.collect_vec();
2730-
if resolved_aliases.contains(&alias_name) {
2743+
if expanded_aliases.contains(&alias_name) {
27312744
return Err(user_error(format!(
27322745
r#"Recursive alias definition involving "{alias_name}""#
27332746
)));
27342747
}
2735-
if let Some(value) = aliases_map.remove(&alias_name) {
2736-
if let Ok(alias_definition) = value.try_deserialize::<Vec<String>>() {
2737-
assert!(string_args.ends_with(&alias_args));
2738-
string_args.truncate(string_args.len() - 1 - alias_args.len());
2739-
string_args.extend(alias_definition);
2740-
string_args.extend_from_slice(&alias_args);
2741-
resolved_aliases.insert(alias_name.clone());
2742-
continue;
2743-
} else {
2744-
return Err(user_error(format!(
2745-
r#"Alias definition for "{alias_name}" must be a string list"#
2746-
)));
2747-
}
2748+
if let Some(alias_definition) = resolved_alias_map.get(&alias_name) {
2749+
assert!(string_args.ends_with(&alias_args));
2750+
string_args.truncate(string_args.len() - 1 - alias_args.len());
2751+
string_args.extend(alias_definition.clone());
2752+
string_args.extend_from_slice(&alias_args);
2753+
expanded_aliases.insert(alias_name.clone());
2754+
continue;
27482755
} else {
27492756
// Not a real command and not an alias, so return what we've resolved so far
2750-
return Ok(string_args);
2757+
break;
27512758
}
27522759
}
27532760
}
27542761
// No more alias commands, or hit unknown option
2755-
return Ok(string_args);
2762+
break;
2763+
}
2764+
2765+
Ok((string_args, resolved_alias_map))
2766+
}
2767+
2768+
fn add_alias_help(app: &mut Command, mut aliases: AliasMap) {
2769+
// Add each alias to `Command` so it shows up in help
2770+
// Aliases may be defined in terms of other aliases, and it's allowed for them to be defined in any order in the TOML config.
2771+
// Repeat this algorithm until we no longer make progress.
2772+
let mut progress;
2773+
let mut try_again_later = HashMap::new();
2774+
loop {
2775+
progress = false;
2776+
for (alias_name, alias_definition) in aliases.drain() {
2777+
// Find the innermost subcommand so we can use its help.
2778+
let mut subcmd = None;
2779+
for arg in &alias_definition {
2780+
if let Some(cmd) = subcmd.unwrap_or(&*app).find_subcommand(arg) {
2781+
subcmd = Some(cmd);
2782+
} else {
2783+
break;
2784+
}
2785+
}
2786+
let Some(subcmd) = subcmd else {
2787+
// We never found a valid subcommand. Skip this iteration; maybe it's an alias to another alias.
2788+
try_again_later.insert(alias_name, alias_definition);
2789+
continue;
2790+
};
2791+
progress = true;
2792+
let help = format!("Alias for {alias_definition:?}");
2793+
let subcmd_alias = subcmd
2794+
.clone()
2795+
.name(alias_name)
2796+
// if we leave the built-in aliases, clap doesn't know whether to call the user alias or
2797+
// the built-in command
2798+
.alias(None)
2799+
.before_help(&help)
2800+
.before_long_help(help);
2801+
*app = std::mem::take(app).subcommand(subcmd_alias);
2802+
}
2803+
if !progress { break; }
2804+
aliases.extend(try_again_later.drain());
2805+
}
2806+
// We may still have some unhandled alias. That means there's an alias with valid toml syntax,
2807+
// but points to a subcommand that doesn't exist. Add help for that too.
2808+
for (name, definition) in try_again_later {
2809+
let help = format!("Alias for {definition:?}");
2810+
// TODO: we can't distinguish empty/option-only aliases from aliases without a subcommand :(
2811+
// This leaves the default "global options" help in case it does happen to be valid.
2812+
// Perhaps we should do `app.try_parse_matches` here so we can distinguish the two?
2813+
let subcmd = Command::new(name).about(help);
2814+
*app = std::mem::take(app).subcommand(subcmd);
27562815
}
27572816
}
27582817

@@ -2791,7 +2850,7 @@ pub fn expand_args(
27912850
app: &Command,
27922851
args_os: ArgsOs,
27932852
config: &config::Config,
2794-
) -> Result<Vec<String>, CommandError> {
2853+
) -> Result<(Vec<String>, AliasMap), CommandError> {
27952854
let mut string_args: Vec<String> = vec![];
27962855
for arg_os in args_os {
27972856
if let Some(string_arg) = arg_os.to_str() {
@@ -2802,7 +2861,7 @@ pub fn expand_args(
28022861
}
28032862

28042863
let string_args = resolve_default_command(ui, config, app, string_args)?;
2805-
resolve_aliases(config, app, string_args)
2864+
expand_aliases(config, app, string_args)
28062865
}
28072866

28082867
pub fn parse_args(
@@ -2999,7 +3058,7 @@ impl CliRunner {
29993058

30003059
#[instrument(skip_all)]
30013060
fn run_internal(
3002-
self,
3061+
mut self,
30033062
ui: &mut Ui,
30043063
mut layered_configs: LayeredConfigs,
30053064
) -> Result<(), CommandError> {
@@ -3021,7 +3080,11 @@ impl CliRunner {
30213080
let config = layered_configs.merge();
30223081
ui.reset(&config)?;
30233082

3024-
let string_args = expand_args(ui, &self.app, env::args_os(), &config)?;
3083+
// save this - since `expand_args` modifies app, it won't parse the same when we
3084+
// process -R
3085+
let original_app = self.app.clone();
3086+
let (string_args, aliases) = expand_args(ui, &self.app, env::args_os(), &config)?;
3087+
add_alias_help(&mut self.app, aliases);
30253088
let (matches, args) = parse_args(
30263089
ui,
30273090
&self.app,
@@ -3050,7 +3113,7 @@ impl CliRunner {
30503113
// If -R is specified, check if the expanded arguments differ. Aliases
30513114
// can also be injected by --config-toml, but that's obviously wrong.
30523115
if args.global_args.repository.is_some() {
3053-
let new_string_args = expand_args(ui, &self.app, env::args_os(), &config).ok();
3116+
let new_string_args = expand_args(ui, &original_app, env::args_os(), &config).ok().map(|(args, _)| args);
30543117
if new_string_args.as_ref() != Some(&string_args) {
30553118
writeln!(
30563119
ui.warning(),

cli/tests/test_alias.rs

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

0 commit comments

Comments
 (0)