From b9e6a2d30afc65eaf8b5002fa8e098d789291975 Mon Sep 17 00:00:00 2001 From: Jiamu Sun <39@barroit.sh> Date: Thu, 23 Apr 2026 10:37:57 +0900 Subject: [PATCH] parseopt: autocorrect mistyped subcommands Try to autocorrect the mistyped mandatory subcommand before showing an error and exiting. Subcommands parsed with PARSE_OPT_SUBCOMMAND_OPTIONAL are skipped. The subcommand autocorrection behaves the same as the command autocorrection. Signed-off-by: Jiamu Sun <39@barroit.sh> Signed-off-by: Junio C Hamano --- autocorrect.h | 4 ++ help.c | 13 ++--- parse-options.c | 102 +++++++++++++++++++++++++++++++++++++-- t/t0040-parse-options.sh | 5 +- t/t7900-maintenance.sh | 4 +- 5 files changed, 112 insertions(+), 16 deletions(-) diff --git a/autocorrect.h b/autocorrect.h index 0d3e819262..14ee7c4548 100644 --- a/autocorrect.h +++ b/autocorrect.h @@ -1,6 +1,10 @@ #ifndef AUTOCORRECT_H #define AUTOCORRECT_H +/* An empirically derived magic number */ +#define AUTOCORRECT_SIMILARITY_FLOOR 7 +#define AUTOCORRECT_SIMILAR_ENOUGH(x) ((x) < AUTOCORRECT_SIMILARITY_FLOOR) + enum autocorrect_mode { AUTOCORRECT_HINT, AUTOCORRECT_NEVER, diff --git a/help.c b/help.c index 81efdb13d4..16e5de973b 100644 --- a/help.c +++ b/help.c @@ -580,10 +580,6 @@ static void add_cmd_list(struct cmdnames *cmds, struct cmdnames *old) old->cnt = 0; } -/* An empirically derived magic number */ -#define SIMILARITY_FLOOR 7 -#define SIMILAR_ENOUGH(x) ((x) < SIMILARITY_FLOOR) - static const char bad_interpreter_advice[] = N_("'%s' appears to be a git command, but we were not\n" "able to execute it. Maybe git-%s is broken?"); @@ -659,7 +655,7 @@ char *help_unknown_cmd(const char *cmd) if (main_cmds.cnt <= n) { /* prefix matches with everything? that is too ambiguous */ - best_similarity = SIMILARITY_FLOOR + 1; + best_similarity = AUTOCORRECT_SIMILARITY_FLOOR + 1; } else { /* count all the most similar ones */ for (best_similarity = main_cmds.names[n++]->len; @@ -670,7 +666,7 @@ char *help_unknown_cmd(const char *cmd) } if (autocorrect.mode != AUTOCORRECT_HINT && n == 1 && - SIMILAR_ENOUGH(best_similarity)) { + AUTOCORRECT_SIMILAR_ENOUGH(best_similarity)) { char *assumed = xstrdup(main_cmds.names[0]->name); fprintf_ln(stderr, @@ -687,11 +683,10 @@ char *help_unknown_cmd(const char *cmd) fprintf_ln(stderr, _("git: '%s' is not a git command. See 'git --help'."), cmd); - if (SIMILAR_ENOUGH(best_similarity)) { + if (AUTOCORRECT_SIMILAR_ENOUGH(best_similarity)) { fprintf_ln(stderr, Q_("\nThe most similar command is", - "\nThe most similar commands are", - n)); + "\nThe most similar commands are", n)); for (i = 0; i < n; i++) fprintf(stderr, "\t%s\n", main_cmds.names[i]->name); diff --git a/parse-options.c b/parse-options.c index 02a4f00919..faf357b729 100644 --- a/parse-options.c +++ b/parse-options.c @@ -6,6 +6,8 @@ #include "strbuf.h" #include "string-list.h" #include "utf8.h" +#include "autocorrect.h" +#include "levenshtein.h" static int disallow_abbreviated_options; @@ -622,13 +624,98 @@ static int parse_subcommand(const char *arg, const struct option *options) return -1; } +static void find_subcommands(struct string_list *list, + const struct option *options) +{ + for (; options->type != OPTION_END; options++) { + if (options->type == OPTION_SUBCOMMAND) + string_list_append(list, options->long_name); + } +} + +static int levenshtein_compare(const void *p1, const void *p2) +{ + const struct string_list_item *i1 = p1, *i2 = p2; + const char *s1 = i1->string, *s2 = i2->string; + int l1 = (intptr_t)i1->util; + int l2 = (intptr_t)i2->util; + + return l1 != l2 ? l1 - l2 : strcmp(s1, s2); +} + +static const char *autocorrect_subcommand(const char *cmd, + struct string_list *cmds) +{ + struct autocorrect autocorrect = { 0 }; + unsigned int n = 0, best = 0; + struct string_list_item *cand; + + autocorrect_resolve(&autocorrect); + + if (autocorrect.mode == AUTOCORRECT_NEVER) + return NULL; + + for_each_string_list_item(cand, cmds) { + if (starts_with(cand->string, cmd)) { + cand->util = 0; + } else { + int edit = levenshtein(cmd, cand->string, + 0, 2, 1, 3) + 1; + + cand->util = (void *)(intptr_t)edit; + } + } + + QSORT(cmds->items, cmds->nr, levenshtein_compare); + + /* Match help.c:help_unknown_cmd */ + for (; n < cmds->nr && !cmds->items[n].util; n++); + + if (n == cmds->nr) + /* prefix matches with every subcommands */ + best = AUTOCORRECT_SIMILARITY_FLOOR + 1; + else + for (best = (intptr_t)cmds->items[n++].util; + (n < cmds->nr && best == (intptr_t)cmds->items[n].util); + n++); + + if (autocorrect.mode != AUTOCORRECT_HINT && n == 1 && + AUTOCORRECT_SIMILAR_ENOUGH(best)) { + fprintf_ln(stderr, + _("WARNING: You called a subcommand named '%s', which does not exist."), + cmd); + + autocorrect_confirm(&autocorrect, cmds->items[0].string); + return cmds->items[0].string; + } + + if (AUTOCORRECT_SIMILAR_ENOUGH(best)) { + error(_("'%s' is not a subcommand."), cmd); + + fprintf_ln(stderr, + Q_("\nThe most similar subcommand is", + "\nThe most similar subcommands are", + n)); + + for (unsigned int i = 0; i < n; i++) + fprintf(stderr, "\t%s\n", cmds->items[i].string); + + exit(129); + } + + return NULL; +} + static enum parse_opt_result handle_subcommand(struct parse_opt_ctx_t *ctx, const char *arg, const struct option *options, const char * const usagestr[]) { - int err = parse_subcommand(arg, options); + int err; + const char *assumed; + struct string_list cmds = STRING_LIST_INIT_NODUP; + err = parse_subcommand(arg, options); if (!err) return PARSE_OPT_SUBCOMMAND; @@ -641,8 +728,17 @@ static enum parse_opt_result handle_subcommand(struct parse_opt_ctx_t *ctx, if (ctx->flags & PARSE_OPT_SUBCOMMAND_OPTIONAL) return PARSE_OPT_DONE; - error(_("unknown subcommand: `%s'"), arg); - usage_with_options(usagestr, options); + find_subcommands(&cmds, options); + assumed = autocorrect_subcommand(arg, &cmds); + + if (!assumed) { + error(_("unknown subcommand: `%s'"), arg); + usage_with_options(usagestr, options); + } + + string_list_clear(&cmds, 0); + parse_subcommand(assumed, options); + return PARSE_OPT_SUBCOMMAND; } static void check_typos(const char *arg, const struct option *options) diff --git a/t/t0040-parse-options.sh b/t/t0040-parse-options.sh index ca55ea8228..2a2fef1e17 100755 --- a/t/t0040-parse-options.sh +++ b/t/t0040-parse-options.sh @@ -632,8 +632,9 @@ test_expect_success 'subcommand - unknown subcommand shows error and usage' ' test_expect_success 'subcommand - subcommands cannot be abbreviated' ' test_expect_code 129 test-tool parse-subcommand cmd subcmd-o 2>err && - grep "^error: unknown subcommand: \`subcmd-o$SQ$" err && - grep ^usage: err + grep "^The most similar subcommands are$" err && + grep "subcmd-one$" err && + grep "subcmd-two$" err ' test_expect_success 'subcommand - no negated subcommands' ' diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh index 4700beacc1..7174b99328 100755 --- a/t/t7900-maintenance.sh +++ b/t/t7900-maintenance.sh @@ -37,8 +37,8 @@ test_systemd_analyze_verify () { test_expect_success 'help text' ' test_expect_code 129 git maintenance -h >actual && test_grep "usage: git maintenance " actual && - test_expect_code 129 git maintenance barf 2>err && - test_grep "unknown subcommand: \`barf'\''" err && + test_expect_code 129 git maintenance abarf 2>err && + test_grep "unknown subcommand: \`abarf'\''" err && test_grep "usage: git maintenance" err && test_expect_code 129 git maintenance 2>err && test_grep "error: need a subcommand" err &&