remkop / picocli

Picocli is a modern framework for building powerful, user-friendly, GraalVM-enabled command line apps with ease. It supports colors, autocompletion, subcommands, and more. In 1 source file so apps can include as source & avoid adding a dependency. Written in Java, usable from Groovy, Kotlin, Scala, etc.
https://picocli.info
Apache License 2.0
4.82k stars 417 forks source link

Add help for mistyped commands #298

Closed charphi closed 6 years ago

charphi commented 6 years ago

When you mistype a command in git command line, it suggests several possible commands instead of listing all. It would be interesting to have the same behaviour with picocli.

For example:

$ git chekcout -b new_branch
git: 'chekcout' is not a git command. See 'git --help'.

Did you mean this?
        checkout
remkop commented 6 years ago

That is a cool feature indeed! Do you think you can provide a pull request for this?

charphi commented 6 years ago

I will try to find some time to do it but I can't promise.

By the way, here is an algorithm that might be used to find proper matches: https://en.wikipedia.org/wiki/Bitap_algorithm

remkop commented 6 years ago

Nice, thanks for the link!

charphi commented 6 years ago

I've looked at the code and I'm not sure where to put changes without breaking the logic. Here is the code I came up so far:

private void internalHandleParseException(ParameterException ex, PrintStream out, Help.Ansi ansi, String[] args) {
    if (ex instanceof UnmatchedArgumentException && !ex.getCommandLine().getSubcommands().isEmpty()) {
        // PRINT SUGGESTION
        String ummatchedParameter = null; // retrieve this from exception
        out.println("Did you mean this?");
        BitapFilter filter = new BitapFilter(ummatchedParameter, 1);
        for (String subCommand : ex.getCommandLine().getSubcommands().keySet()) {
            if (filter.test(subCommand)) {
                out.println(subCommand);
            }
        }
    } else {
        out.println(ex.getMessage());
        ex.getCommandLine().usage(out, ansi);
    }
}

// https://en.wikipedia.org/wiki/Bitap_algorithm
static final class BitapFilter implements Predicate<String> {

    private final int alphabetRange = 128;
    private final long[] patternMask;
    private final int patternLength;
    private final int k;

    public BitapFilter(String pattern, int k) {
        /* Initialize the pattern bitmasks */
        this.patternMask = new long[alphabetRange];
        for (int i = 0; i < pattern.length(); ++i) {
            patternMask[(int) pattern.charAt(i)] |= 1 << i;
        }
        this.patternLength = pattern.length();
        this.k = k;
    }

    @Override
    public boolean test(String text) {
        /* Initialize the bit array R */
        long[] r = new long[k + 1];
        for (int i = 0; i <= k; i++) {
            r[i] = 1;
        }
        /* Performs test */
        for (int i = 0; i < text.length(); i++) {
            long old = 0;
            long nextOld = 0;

            for (int d = 0; d <= k; ++d) {
                // Three operations of the Levenshtein distance
                long sub = (old | (r[d] & patternMask[text.charAt(i)])) << 1;
                long ins = old | ((r[d] & patternMask[text.charAt(i)]) << 1);
                long del = (nextOld | (r[d] & patternMask[text.charAt(i)])) << 1;
                old = r[d];
                r[d] = sub | ins | del | 1;
                nextOld = r[d];
            }
            // When r[k] is full of zeros, it means we matched the pattern
            // (modulo k errors)
            if (0 < (r[k] & (1 << patternLength))) {
                return true;
            }
        }
        return false;
    }
}
remkop commented 6 years ago

I haven't looked at this in detail yet, but perhaps the DefaultExceptionHandler would be a good place?

remkop commented 6 years ago

CosineSimilarity may also be interesting.

It is used like this:

context.console.error("Command not found ${commandName}")
def mostSimilar = CosineSimilarity.mostSimilar(commandName, commandsByName.keySet())
List<String> topMatches = mostSimilar.subList(0, Math.min(3, mostSimilar.size()));
if (topMatches) {
    context.console.log("Did you mean: ${topMatches.join(' or ')}?")
}
remkop commented 6 years ago

This is fixed in the latest release https://github.com/remkop/picocli/releases/tag/v3.3.0 . Thanks for the suggestion!

charphi commented 6 years ago

You are welcome :) And thank you for implementing it. This will be really useful for someone like me who does typos all the time.