MEDIUM: cli: apply spelling fixes for known commands before listing them

Entering "show tls" would still emit 35 entries. By measuring the distance
between all unknown words and the candidates, we can sort them and pick the
10 most likely candidates. This works reasonably well, as now "show tls"
only proposes "show tls-keys", "show threads", "show pools" and "show tasks".

If the distance is still too high or if a word is missing, the whole
prefix list continues to be dumped, thus "show" alone will still report
the entire list of commands beginning with "show".

It's still impossible to skip a word, for example "show conn" will not
propose "show servers conn" because the distance is calculated for each
word individually. Some changes to the distance calculation to support
updating an existing map could easily address this. But this is already
a great improvement.
diff --git a/include/haproxy/cli-t.h b/include/haproxy/cli-t.h
index 82d4875..61c9881 100644
--- a/include/haproxy/cli-t.h
+++ b/include/haproxy/cli-t.h
@@ -42,6 +42,7 @@
 #define APPCTX_CLI_ST1_NOLF    (1 << 2)
 
 #define CLI_PREFIX_KW_NB 5
+#define CLI_MAX_MATCHES 10
 
 /* CLI states */
 enum {
diff --git a/src/cli.c b/src/cli.c
index 0272726..8a6f827 100644
--- a/src/cli.c
+++ b/src/cli.c
@@ -94,6 +94,7 @@
 	struct cli_kw *kw;
 	struct buffer *tmp = get_trash_chunk();
 	struct buffer out;
+	struct { struct cli_kw *kw; int dist; } matches[CLI_MAX_MATCHES], swp;
 	int idx;
 	int length = 0;
 
@@ -122,16 +123,77 @@
 		}
 	}
 
-	/* now <length> equals the number of matching words */
-
+	/* now <length> equals the number of exactly matching words */
 	chunk_reset(tmp);
 	if (!args) // this is the help message.
 		chunk_strcat(tmp, "The following commands are valid at this level:\n");
-	else if (!length) // no match
+	else if (!length && (!*args || !**args)) // no match
 		chunk_strcat(tmp, "Unknown command. Please enter one of the following commands only:\n");
 	else // partial match
 		chunk_strcat(tmp, "Unknown command, but maybe one of the following ones is a better match:\n");
 
+	for (idx = 0; idx < CLI_MAX_MATCHES; idx++) {
+		matches[idx].kw = NULL;
+		matches[idx].dist = INT_MAX;
+	}
+
+	/* In case of partial match we'll look for the best matching entries
+	 * starting from position <length>
+	 */
+	if (args[length] && *args[length]) {
+		list_for_each_entry(kw_list, &cli_keywords.list, list) {
+			for (kw = &kw_list->kw[0]; kw->str_kw[0]; kw++) {
+				if (kw->level & ~appctx->cli_level & (ACCESS_MASTER_ONLY|ACCESS_EXPERT))
+					continue;
+				if ((appctx->cli_level & ~kw->level & (ACCESS_MASTER_ONLY|ACCESS_MASTER)) ==
+				    (ACCESS_MASTER_ONLY|ACCESS_MASTER))
+					continue;
+
+				for (idx = 0; idx < length; idx++) {
+					if (!kw->str_kw[idx])
+						break; // end of keyword
+					if (!args || !args[idx] || !*args[idx])
+						break; // end of command line
+					if (strcmp(kw->str_kw[idx], args[idx]) != 0)
+						break;
+				}
+
+				/* extra non-matching words are fuzzy-matched */
+				if (kw->usage && idx == length && args[idx] && *args[idx]) {
+					uint8_t word_sig[1024];
+					uint8_t list_sig[1024];
+					int dist = 0;
+					int totlen = 0;
+
+					/* this one matches, let's compute the distance between the two
+					 * on the remaining words
+					 */
+					while (idx < CLI_PREFIX_KW_NB && kw->str_kw[idx] && args[idx] && *args[idx]) {
+						make_word_fingerprint(word_sig, args[idx]);
+						make_word_fingerprint(list_sig, kw->str_kw[idx]);
+						totlen += strlen(args[idx]) + strlen(kw->str_kw[idx]);
+						dist += word_fingerprint_distance(word_sig, list_sig);
+						idx++;
+					}
+
+					/* insert this one at its place if relevant, in order to keep only
+					 * the best matches.
+					 */
+					swp.kw = kw; swp.dist = dist;
+					if (dist < totlen && dist < matches[CLI_MAX_MATCHES-1].dist) {
+						matches[CLI_MAX_MATCHES-1] = swp;
+						for (idx = CLI_MAX_MATCHES - 1; --idx >= 0;) {
+							if (matches[idx+1].dist >= matches[idx].dist)
+								break;
+							matches[idx+1] = matches[idx];
+							matches[idx] = swp;
+						}
+					}
+				}
+			}
+		}
+	}
+
 	list_for_each_entry(kw_list, &cli_keywords.list, list) {
 		for (kw = &kw_list->kw[0]; kw->str_kw[0]; kw++) {
 
@@ -157,8 +219,19 @@
 					break;
 			}
 
-			if (kw->usage && idx == length)
-				chunk_appendf(tmp, "  %s\n", kw->usage);
+			if (kw->usage && idx == length) {
+				/* if we've filled some fuzzy matches, let's compare them. We'll
+				 * just set the idx to their position if they're found and are no
+				 * further than twice the distance of the best match, otherwise
+				 * idx will be CLI_MAX_MATCHES, indicating "not found".
+				 */
+				for (idx = 0; matches[0].kw && idx < CLI_MAX_MATCHES; idx++)
+					if (kw == matches[idx].kw && matches[idx].dist <= 2*matches[0].dist)
+						break;
+
+				if (idx < CLI_MAX_MATCHES)
+					chunk_appendf(tmp, "  %s\n", kw->usage);
+			}
 		}
 	}