Skip to content

Commit 8c7bfab

Browse files
committed
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 8c7bfab

File tree

3 files changed

+90
-21
lines changed

3 files changed

+90
-21
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

+51-19
Original file line numberDiff line numberDiff line change
@@ -2684,7 +2684,7 @@ fn resolve_default_command(
26842684

26852685
fn resolve_aliases(
26862686
config: &config::Config,
2687-
app: &Command,
2687+
app: &mut Command,
26882688
mut string_args: Vec<String>,
26892689
) -> Result<Vec<String>, CommandError> {
26902690
let mut aliases_map = config.get_table("aliases")?;
@@ -2716,6 +2716,16 @@ fn resolve_aliases(
27162716
}
27172717
}
27182718

2719+
let mut resolved_alias_map = HashMap::new();
2720+
for (alias_name, value) in aliases_map {
2721+
let Ok(alias_definition) = value.try_deserialize::<Vec<String>>() else {
2722+
return Err(user_error(format!(
2723+
r#"Alias definition for "{alias_name}" must be a string list"#
2724+
)));
2725+
};
2726+
resolved_alias_map.insert(alias_name, alias_definition);
2727+
}
2728+
27192729
loop {
27202730
let app_clone = app.clone().allow_external_subcommands(true);
27212731
let matches = app_clone.try_get_matches_from(&string_args).ok();
@@ -2732,28 +2742,47 @@ fn resolve_aliases(
27322742
r#"Recursive alias definition involving "{alias_name}""#
27332743
)));
27342744
}
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-
}
2745+
if let Some(alias_definition) = resolved_alias_map.get(&alias_name) {
2746+
assert!(string_args.ends_with(&alias_args));
2747+
string_args.truncate(string_args.len() - 1 - alias_args.len());
2748+
string_args.extend(alias_definition.clone());
2749+
string_args.extend_from_slice(&alias_args);
2750+
resolved_aliases.insert(alias_name.clone());
2751+
continue;
27482752
} else {
27492753
// Not a real command and not an alias, so return what we've resolved so far
27502754
return Ok(string_args);
27512755
}
27522756
}
27532757
}
27542758
// No more alias commands, or hit unknown option
2755-
return Ok(string_args);
2759+
break;
27562760
}
2761+
2762+
// Add each alias to `Command` so it shows up in help
2763+
for (alias_name, alias_definition) in resolved_alias_map {
2764+
// Find the innermost subcommand so we can use its help.
2765+
let mut subcmd = &*app;
2766+
for arg in &alias_definition {
2767+
if let Some(cmd) = subcmd.find_subcommand(arg) {
2768+
subcmd = cmd;
2769+
} else {
2770+
break;
2771+
}
2772+
}
2773+
let help = format!("Alias for {alias_definition:?}");
2774+
let subcmd_alias = subcmd
2775+
.clone()
2776+
.name(&alias_name)
2777+
// if we leave the built-in aliases, clap doesn't know whether to call the user alias or
2778+
// the built-in command
2779+
.alias(None)
2780+
.before_help(&help)
2781+
.before_long_help(help);
2782+
*app = std::mem::take(app).subcommand(subcmd_alias);
2783+
}
2784+
2785+
Ok(string_args)
27572786
}
27582787

27592788
/// Parse args that must be interpreted early, e.g. before printing help.
@@ -2788,7 +2817,7 @@ fn handle_early_args(
27882817

27892818
pub fn expand_args(
27902819
ui: &Ui,
2791-
app: &Command,
2820+
app: &mut Command,
27922821
args_os: ArgsOs,
27932822
config: &config::Config,
27942823
) -> Result<Vec<String>, CommandError> {
@@ -2999,7 +3028,7 @@ impl CliRunner {
29993028

30003029
#[instrument(skip_all)]
30013030
fn run_internal(
3002-
self,
3031+
mut self,
30033032
ui: &mut Ui,
30043033
mut layered_configs: LayeredConfigs,
30053034
) -> Result<(), CommandError> {
@@ -3021,7 +3050,10 @@ impl CliRunner {
30213050
let config = layered_configs.merge();
30223051
ui.reset(&config)?;
30233052

3024-
let string_args = expand_args(ui, &self.app, env::args_os(), &config)?;
3053+
// save this - since `expand_args` modifies app, it won't parse the same when we
3054+
// process -R
3055+
let mut original_app = self.app.clone();
3056+
let string_args = expand_args(ui, &mut self.app, env::args_os(), &config)?;
30253057
let (matches, args) = parse_args(
30263058
ui,
30273059
&self.app,
@@ -3050,7 +3082,7 @@ impl CliRunner {
30503082
// If -R is specified, check if the expanded arguments differ. Aliases
30513083
// can also be injected by --config-toml, but that's obviously wrong.
30523084
if args.global_args.repository.is_some() {
3053-
let new_string_args = expand_args(ui, &self.app, env::args_os(), &config).ok();
3085+
let new_string_args = expand_args(ui, &mut original_app, env::args_os(), &config).ok();
30543086
if new_string_args.as_ref() != Some(&string_args) {
30553087
writeln!(
30563088
ui.warning(),

cli/tests/test_alias.rs

+35-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,31 @@ 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+
356+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "b"]);
357+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
358+
Alias for ["log", "-r", "@", "-T", "branches"]
359+
360+
Show commit history
361+
"###);
362+
let stdout = test_env.jj_cmd_success(&repo_path, &["b", "-h"]);
363+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Show commit history");
364+
365+
let stdout = test_env.jj_cmd_success(&repo_path, &["help", "s"]);
366+
insta::assert_snapshot!(stdout.lines().take(3).join("\n"), @r###"
367+
Alias for ["status"]
368+
369+
Show high-level repo status
370+
"###);
371+
let stdout = test_env.jj_cmd_success(&repo_path, &["s", "-h"]);
372+
insta::assert_snapshot!(stdout.lines().next().unwrap(), @"Show high-level repo status");
373+
}

0 commit comments

Comments
 (0)