diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a553760b..da8ff6e87c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Add a new template alias `bultin_log_compact_full_description()`. +* `jj help` now has the flag `--keyword`(shorthand `-k`), which can give help + for some keywords(e.g. `jj help -k revsets`). To see a list of the available + keywords you can do `jj help`, `jj --help` or `jj help -k`. + ### Fixed bugs * Error on `trunk()` revset resolution is now handled gracefully. diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index 291183a8aa..d5165ad8e2 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -207,7 +207,7 @@ pub fn internal_error_with_message( CommandError::with_message(CommandErrorKind::Internal, message, source) } -fn format_similarity_hint>(candidates: &[S]) -> Option { +pub fn format_similarity_hint>(candidates: &[S]) -> Option { match candidates { [] => None, names => { diff --git a/cli/src/commands/help.rs b/cli/src/commands/help.rs index 364cbe7fca..4cf00820ce 100644 --- a/cli/src/commands/help.rs +++ b/cli/src/commands/help.rs @@ -12,11 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::fmt::Write as _; +use std::io::Write; + +use clap::builder::StyledStr; +use crossterm::style::Stylize; +use itertools::Itertools; use tracing::instrument; use crate::cli_util::CommandHelper; -use crate::command_error; use crate::command_error::CommandError; +use crate::command_error::{self}; use crate::ui::Ui; /// Print this message or the help of the given subcommand(s) @@ -24,26 +30,140 @@ use crate::ui::Ui; pub(crate) struct HelpArgs { /// Print help for the subcommand(s) pub(crate) command: Vec, + // Show help for keywords instead of commands + #[arg(long, short = 'k')] + keyword: bool, } #[instrument(skip_all)] pub(crate) fn cmd_help( - _ui: &mut Ui, + ui: &mut Ui, command: &CommandHelper, args: &HelpArgs, ) -> Result<(), CommandError> { - let mut args_to_show_help = vec![command.app().get_name()]; - args_to_show_help.extend(args.command.iter().map(|s| s.as_str())); - args_to_show_help.push("--help"); - - // TODO: `help log -- -r` will gives an cryptic error, ideally, it should state - // that the subcommand `log -r` doesn't exist. - let help_err = command - .app() - .clone() - .subcommand_required(true) - .try_get_matches_from(args_to_show_help) - .expect_err("Clap library should return a DisplayHelp error in this context"); - - Err(command_error::cli_error(help_err)) + if args.keyword { + if let [name] = &*args.command { + if let Some(keyword) = find_keyword(name) { + ui.request_pager(); + write!(ui.stdout(), "{}", keyword.content)?; + + return Ok(()); + } else { + let error_str = format!("No help found for keyword \"{name}\""); + let similar_keywords = &KEYWORDS + .iter() + .map(|keyword| keyword.name) + .filter(|str| strsim::jaro(name, str) > 0.7) + .collect_vec(); + + if let Some(similar_keywords) = + command_error::format_similarity_hint(similar_keywords) + { + return Err(command_error::user_error_with_hint( + error_str, + similar_keywords, + )); + } else { + return Err(command_error::user_error(error_str)); + } + } + } + + let keyword_list = if ui.color() { + format_keywords().ansi().to_string() + } else { + format_keywords().to_string() + }; + + write!(ui.stdout(), "{keyword_list}")?; + + return Ok(()); + } else { + let mut args_to_show_help = vec![command.app().get_name()]; + args_to_show_help.extend(args.command.iter().map(|s| s.as_str())); + args_to_show_help.push("--help"); + + // TODO: `help log -- -r` will gives an cryptic error, ideally, it should state + // that the subcommand `log -r` doesn't exist. + let help_err = command + .app() + .clone() + .subcommand_required(true) + .try_get_matches_from(args_to_show_help) + .expect_err("Clap library should return a DisplayHelp error in this context"); + + Err(command_error::cli_error(help_err)) + } +} + +#[derive(Clone)] +struct Keyword { + name: &'static str, + description: &'static str, + content: &'static str, +} + +// TODO: Add all documentation to keywords +// +// Maybe adding some code to build.rs to find all the docs files and build the +// `KEYWORDS` at compile time. +// +// It would be cool to follow the docs hierarchy somehow. +// +// One of the problems would be `config.md`, as it has the same name as a +// subcommand. +// +// TODO: Find a way to render markdown using ANSI escape codes. +// +// Maybe we can steal some ideas from https://github.com/martinvonz/jj/pull/3130 +const KEYWORDS: &[Keyword] = &[ + Keyword { + name: "revsets", + description: "A functional language for selecting a set of revision", + content: include_str!("../../../docs/revsets.md"), + }, + Keyword { + name: "tutorial", + description: "Show a tutorial to get started with jj", + content: include_str!("../../../docs/tutorial.md"), + }, +]; + +fn find_keyword(name: &str) -> Option<&Keyword> { + KEYWORDS.iter().find(|keyword| keyword.name == name) +} + +fn format_keywords() -> StyledStr { + let keyword_name_max_len = KEYWORDS + .iter() + .map(|keyword| keyword.name.len()) + .max() + .unwrap(); + + let mut ret = String::new(); + + writeln!(ret, "{}", "Help Keywords:".bold().underlined()).unwrap(); + for keyword in KEYWORDS { + write!( + ret, + " {} ", + format!("{: StyledStr { + let mut ret = StyledStr::new(); + writeln!( + ret, + "Use {} to show help related to these keywords", + "'jj help -k'".bold() + ) + .unwrap(); + write!(ret, "{}", format_keywords()).unwrap(); + return ret; } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 25bf0a32f1..fe09f494ca 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -73,6 +73,7 @@ use crate::ui::Ui; #[derive(clap::Parser, Clone, Debug)] #[command(disable_help_subcommand = true)] +#[command(after_long_help = help::format_keywords_for_after_help())] enum Command { Abandon(abandon::AbandonArgs), Backout(backout::BackoutArgs), diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 0589b9a41d..d4fa044828 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -112,6 +112,11 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor **Usage:** `jj [OPTIONS] [COMMAND]` +Help Categories: + revsets A functional language for selecting a set of revision + tutorial Show a tutorial to get started with jj + + ###### **Subcommands:** * `abandon` — Abandon a revision @@ -1196,12 +1201,16 @@ Set the URL of a Git remote Print this message or the help of the given subcommand(s) -**Usage:** `jj help [COMMAND]...` +**Usage:** `jj help [OPTIONS] [COMMAND]...` ###### **Arguments:** * `` — Print help for the subcommand(s) +###### **Options:** + +* `-k`, `--keyword` + ## `jj init` diff --git a/cli/tests/test_help_command.rs b/cli/tests/test_help_command.rs index 8c8d0d12f6..9395524111 100644 --- a/cli/tests/test_help_command.rs +++ b/cli/tests/test_help_command.rs @@ -84,3 +84,60 @@ fn test_help() { For more information, try '--help'. "#); } + +#[test] +fn test_help_keyword() { + let test_env = TestEnvironment::default(); + + // Capture 'Help Keywords' part + let help_cmd_stdout = test_env.jj_cmd_success(test_env.env_root(), &["help"]); + insta::with_settings!({filters => vec![ + (r"(?s).+\n\n(?.*Help Keywords:.+)", "$h"), + ]}, { + insta::assert_snapshot!(help_cmd_stdout, @r#" + Use 'jj help -k' to show help related to these keywords + Help Keywords: + revsets A functional language for selecting a set of revision + tutorial Show a tutorial to get started with jj + "#); + }); + + // It should show help for a certain keyword if the `--keyword` flag is present + let help_cmd_stdout = + test_env.jj_cmd_success(test_env.env_root(), &["help", "--keyword", "revsets"]); + // It should be equal to the docs + assert_eq!(help_cmd_stdout, include_str!("../../docs/revsets.md")); + + // It should show help for a certain keyword if the `-k` flag is present + let help_cmd_stdout = test_env.jj_cmd_success(test_env.env_root(), &["help", "-k", "revsets"]); + // It should be equal to the docs + assert_eq!(help_cmd_stdout, include_str!("../../docs/revsets.md")); + + // It should give hints if a similar keyword is present + let help_cmd_stderr = test_env.jj_cmd_failure(test_env.env_root(), &["help", "-k", "rev"]); + insta::assert_snapshot!(help_cmd_stderr, @r#" + Error: No help found for keyword "rev" + Hint: Did you mean "revsets"? + "#); + + // The keyword flag with no argument should print the keyword list + let help_cmd_stdout = test_env.jj_cmd_success(test_env.env_root(), &["help", "-k"]); + insta::assert_snapshot!(help_cmd_stdout, @r#" + Help Keywords: + revsets A functional language for selecting a set of revision + tutorial Show a tutorial to get started with jj + "#); + + // It shouldn't show help for a certain keyword if the `--keyword` is not + // present + let help_cmd_stderr = test_env.jj_cmd_cli_error(test_env.env_root(), &["help", "revsets"]); + insta::assert_snapshot!(help_cmd_stderr, @r#" + error: unrecognized subcommand 'revsets' + + tip: some similar subcommands exist: 'resolve', 'prev', 'restore', 'rebase', 'revert' + + Usage: jj [OPTIONS] + + For more information, try '--help'. + "#); +}