Skip to content

Commit 5b8b6f2

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 recursive-alias` will only "peel" one level of aliasing in the help; it would be nice to show the fully expanded command instead.
1 parent 0d3e949 commit 5b8b6f2

File tree

5 files changed

+233
-30
lines changed

5 files changed

+233
-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

+122-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,106 @@ 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.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 = &mut *app;
2705+
let mut depth = 0;
2706+
for arg in &alias_definition {
2707+
if subcmd.find_subcommand_mut(arg).is_some() {
2708+
// hack: without -Zpolonius, the borrow checker thinks the command in the if
2709+
// condition isn't assignable
2710+
subcmd = subcmd.find_subcommand_mut(arg).unwrap();
2711+
depth += 1;
2712+
} else {
2713+
break;
2714+
}
2715+
}
2716+
2717+
if depth == 0 {
2718+
// We never found a valid subcommand. Skip this iteration; maybe it's an alias
2719+
// to another alias.
2720+
try_again_later.insert(alias_name, alias_definition);
2721+
continue;
2722+
};
2723+
progress = true;
2724+
2725+
if depth == 1 && alias_definition.len() == 1 {
2726+
// if this definition has no arguments, we can add it as a `visible_alias`,
2727+
// which renders nicer. note that this only works for the root
2728+
// command; for some reason, `visible_alias` does not seem to propagate from
2729+
// subcommands to the root :(
2730+
*subcmd = subcmd.clone().visible_alias(alias_name);
2731+
} else {
2732+
// have to add a standalone subcmd
2733+
let help = render_definition(&alias_definition);
2734+
let subcmd_alias = subcmd
2735+
.clone()
2736+
.name(alias_name)
2737+
// if we leave the built-in aliases, clap doesn't know whether to call the user
2738+
// alias or the built-in command
2739+
.alias(None)
2740+
// TODO: for recursive aliases, it would be nice to show the full args this
2741+
// expands to rather than just the immediately next alias
2742+
.before_help(&help)
2743+
.before_long_help(help);
2744+
*app = std::mem::take(app).subcommand(subcmd_alias);
2745+
}
2746+
}
2747+
if !progress {
2748+
break;
2749+
}
2750+
aliases.extend(try_again_later.drain());
2751+
}
2752+
// We may still have some unhandled aliases. That means there's an alias with
2753+
// valid toml syntax, but points to a subcommand that doesn't exist. Add
2754+
// help for that too.
2755+
for (name, definition) in try_again_later {
2756+
let help = render_definition(&definition);
2757+
// TODO: we can't distinguish empty/option-only aliases from aliases without a
2758+
// subcommand :( This leaves the default "global options" help in case
2759+
// it does happen to be valid. Perhaps we should do
2760+
// `app.try_parse_matches` here so we can distinguish the two?
2761+
let subcmd = Command::new(name).about(help);
2762+
*app = std::mem::take(app).subcommand(subcmd);
26752763
}
26762764
}
26772765

@@ -2713,7 +2801,7 @@ pub fn expand_args(
27132801
app: &Command,
27142802
args_os: ArgsOs,
27152803
config: &config::Config,
2716-
) -> Result<Vec<String>, CommandError> {
2804+
) -> Result<(Vec<String>, AliasMap), CommandError> {
27172805
let mut string_args: Vec<String> = vec![];
27182806
for arg_os in args_os {
27192807
if let Some(string_arg) = arg_os.to_str() {
@@ -2724,7 +2812,7 @@ pub fn expand_args(
27242812
}
27252813

27262814
let string_args = resolve_default_command(ui, config, app, string_args)?;
2727-
resolve_aliases(ui, config, app, string_args)
2815+
expand_aliases(ui, config, app, string_args)
27282816
}
27292817

27302818
pub fn parse_args(
@@ -2903,7 +2991,7 @@ impl CliRunner {
29032991

29042992
#[instrument(skip_all)]
29052993
fn run_internal(
2906-
self,
2994+
mut self,
29072995
ui: &mut Ui,
29082996
mut layered_configs: LayeredConfigs,
29092997
) -> Result<(), CommandError> {
@@ -2929,7 +3017,11 @@ impl CliRunner {
29293017
let config = layered_configs.merge();
29303018
ui.reset(&config)?;
29313019

2932-
let string_args = expand_args(ui, &self.app, env::args_os(), &config)?;
3020+
// save this - since `expand_args` modifies app, it won't parse the same when we
3021+
// process -R
3022+
let original_app = self.app.clone();
3023+
let (string_args, aliases) = expand_args(ui, &self.app, env::args_os(), &config)?;
3024+
add_alias_help(&mut self.app, aliases);
29333025
let (matches, args) = parse_args(
29343026
ui,
29353027
&self.app,
@@ -2959,7 +3051,9 @@ impl CliRunner {
29593051
// If -R is specified, check if the expanded arguments differ. Aliases
29603052
// can also be injected by --config-toml, but that's obviously wrong.
29613053
if args.global_args.repository.is_some() {
2962-
let new_string_args = expand_args(ui, &self.app, env::args_os(), &config).ok();
3054+
let new_string_args = expand_args(ui, &original_app, env::args_os(), &config)
3055+
.ok()
3056+
.map(|(args, _)| args);
29633057
if new_string_args.as_ref() != Some(&string_args) {
29643058
writeln!(
29653059
ui.warning_default(),

0 commit comments

Comments
 (0)