feat(xce-test-cli-ux): add flag typo-suggestion and help-block renderer

flagspec_reject() now names a likely-intended flag when an unrecognized
flag closely matches exactly one entry in the descriptor table (genuine
Levenshtein DP, silent on zero or ambiguous matches). flagspec_render_help()
generates the short/long/required-optional/default flag-listing block from
a descriptor table. 1278/1278 controller suite passing (+5).

Task 1.2 of .kiro/specs/xce-test-cli-ux/tasks.md.
Requirements: 6.1, 6.2, 6.3, 6.4, 8.2, 10.1, 10.2, 10.3

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
This commit is contained in:
Rongsong Shen 2026-09-09 07:40:46 +00:00
parent 0748f10cc6
commit 89af46af3e
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
4 changed files with 335 additions and 17 deletions

View file

@ -3,6 +3,7 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* True iff `token` (a raw command-line argument, e.g. "-h", "--host", or
@ -80,6 +81,139 @@ int flagspec_build_opts(const flagspec_t* spec, size_t n,
return 0;
}
/* Levenshtein edit distance between two NUL-terminated strings -- the
* minimum number of single-character insertions/deletions/substitutions
* that turns `a` into `b`. Small and self-contained: research.md confirms
* no edit-distance utility exists anywhere in the codebase yet. Standard
* two-row rolling DP, O(len(a)*len(b)) time, O(min(len(a),len(b))+1)
* space -- more than fast enough for flag-name-sized strings.
*
* Returns (size_t)-1 on allocation failure, which callers must treat as
* "not a close match" rather than a false zero-distance/exact-match
* signal. */
static size_t levenshtein(const char* a, const char* b)
{
size_t la = strlen(a), lb = strlen(b);
/* Keep the rolling row sized to the SHORTER string. */
if (lb > la) {
const char* tmp = a;
a = b;
b = tmp;
size_t t = la;
la = lb;
lb = t;
}
size_t* row = calloc(lb + 1, sizeof(size_t));
if (!row)
return (size_t)-1;
for (size_t j = 0; j <= lb; j++)
row[j] = j;
for (size_t i = 1; i <= la; i++) {
size_t prev_diag = row[0];
row[0] = i;
for (size_t j = 1; j <= lb; j++) {
size_t saved = row[j];
size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
size_t del = row[j] + 1;
size_t ins = row[j - 1] + 1;
size_t sub = prev_diag + cost;
size_t best = del < ins ? del : ins;
if (sub < best)
best = sub;
row[j] = best;
prev_diag = saved;
}
}
size_t result = row[lb];
free(row);
return result;
}
/* True iff `candidate` (one flag's literal spelling, e.g. "-h" or
* "--host") "closely matches" `bad_flag` for typo-suggestion purposes
* (Requirement 10.1).
*
* Threshold, a deliberate design choice (design.md/research.md do not
* mandate a specific algorithm or cutoff): floor(len(candidate)/4),
* clamped to [1,3]. A fixed absolute threshold would be wrong at both
* ends -- distance 1 from a 2-character short flag ("-h" vs "-z") is a
* near-total rewrite, while distance 1 from an 8+ character long flag is
* a single typo -- so the tolerance scales with the spelling's own
* length instead. Exact matches (distance 0) are never "close" here:
* flagspec_reject() only calls this from the already-unrecognized branch,
* where an exact-spelling candidate would be a contradiction, and this
* keeps the helper correct standalone too. */
static int is_close_match(const char* bad_flag, const char* candidate)
{
size_t dist = levenshtein(bad_flag, candidate);
if (dist == 0 || dist == (size_t)-1)
return 0;
size_t len = strlen(candidate);
size_t threshold = len / 4;
if (threshold < 1)
threshold = 1;
if (threshold > 3)
threshold = 3;
return dist <= threshold;
}
/* Writes `fs`'s display label ("-h/--host" or, for a long-only flag,
* "--json") into `buf`. */
static void format_flag_label(char* buf, size_t cap, const flagspec_t* fs)
{
if (fs->short_opt)
snprintf(buf, cap, "-%c/--%s", fs->short_opt, fs->long_opt);
else
snprintf(buf, cap, "--%s", fs->long_opt);
}
/* Returns the single flagspec_t in `spec` whose short or long spelling
* closely matches `bad_flag`, or NULL when zero or more than one entry
* qualifies -- Requirement 10.1 only promises a guess when exactly one
* flag is a plausible intended target; an ambiguous typo (two or more
* equally-plausible candidates) gets no guess at all, the same as a typo
* that matches nothing (Requirement 10.2). */
static const flagspec_t* find_typo_suggestion(const flagspec_t* spec, size_t n,
const char* bad_flag)
{
if (!spec || !bad_flag || !*bad_flag)
return NULL;
const flagspec_t* found = NULL;
for (size_t i = 0; i < n; i++) {
const flagspec_t* fs = &spec[i];
int is_close = 0;
if (fs->short_opt) {
char short_form[3] = { '-', fs->short_opt, '\0' };
if (is_close_match(bad_flag, short_form))
is_close = 1;
}
if (!is_close && fs->long_opt && *fs->long_opt) {
char long_form[128];
snprintf(long_form, sizeof(long_form), "--%s", fs->long_opt);
if (is_close_match(bad_flag, long_form))
is_close = 1;
}
if (is_close) {
if (found)
return NULL; /* ambiguous -- more than one candidate */
found = fs;
}
}
return found;
}
int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
const char* bad_flag, const char* usage_text)
{
@ -103,8 +237,18 @@ int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
fprintf(stderr, "xce_test: %s: flag '%s' does not take a value\n",
who, display);
} else {
fprintf(stderr, "xce_test: %s: unrecognized flag '%s'\n", who,
display);
const flagspec_t* suggestion = find_typo_suggestion(spec, n, bad_flag);
if (suggestion) {
char label[144];
format_flag_label(label, sizeof(label), suggestion);
fprintf(stderr,
"xce_test: %s: unrecognized flag '%s' (did you mean "
"'%s'?)\n",
who, display, label);
} else {
fprintf(stderr, "xce_test: %s: unrecognized flag '%s'\n", who,
display);
}
}
if (usage_text && *usage_text) {
@ -117,3 +261,38 @@ int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
return -EINVAL;
}
char* flagspec_render_help(const flagspec_t* spec, size_t n)
{
if (!spec || n == 0)
return NULL;
char* buf = NULL;
size_t size = 0;
FILE* f = open_memstream(&buf, &size);
if (!f)
return NULL;
for (size_t i = 0; i < n; i++) {
const flagspec_t* fs = &spec[i];
if (fs->short_opt)
fprintf(f, " -%c, --%s", fs->short_opt, fs->long_opt);
else
fprintf(f, " --%s", fs->long_opt);
fprintf(f, " %s", fs->required ? "required" : "optional");
if (fs->default_desc && *fs->default_desc)
fprintf(f, " (default: %s)", fs->default_desc);
fprintf(f, "\n");
}
if (fclose(f) != 0) {
free(buf);
return NULL;
}
return buf;
}