Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support unambiguous abbreviations #732

Closed
adamsmd opened this issue Jun 16, 2019 · 1 comment · Fixed by #1047
Closed

Support unambiguous abbreviations #732

adamsmd opened this issue Jun 16, 2019 · 1 comment · Fixed by #1047
Labels
theme: parser An issue or change related to the parser type: enhancement ✨
Milestone

Comments

@adamsmd
Copy link

adamsmd commented Jun 16, 2019

Some programs allow subcommands to be abbreviated with any unambiguous abbreviation of the subcommand name. I would like to request the ability to allow this in picocli.

The key here is that these are not explicitly listed abbreviations, but rather any unambiguous abbreviation of the command name or possibly even its aliases.

For example, in Mercurial, hg showconfig can be run with hg showc, hg show or hg sho. However hg sh and hg s are ambiguous and generate the following errors. (At least they do with the set of Mercurial plugins I have installed. Yours may be different.)

$ hg sh
hg: command 'sh' is ambiguous:
    shelve showconfig
$ hg s
hg: command 's' is ambiguous:
    serve shelve showconfig split squash stabilize status strip summary

Another example is the btrfs filesystem command in which btrfs fi usa / and btrfs f u / are the same as btrfs filesystem usage /.

This concept could also extend to option names. For example Python's argparse, supports this with the allow_abbrev setting.

In the cases I have seen this implemented, "unambiguous abbreviation" really means "unambiguous prefix", but there might be generalizations. (This is not critical to me. It is just a possible point in the design space.) For example, you might allow "foo-bar" to be abbreviated as "f-b" (under the rule that "-" separated words can be abbreviated individually) or "fb" or "fr" (under the rule that abbreviations are subsequences). The latter might be impractical to use (e.g., "fr" becomes ambiguous between "free" and "foo-bar"). I only mention it as it is what is used by Emacs Helm, albeit for interactive input not command lines.

@remkop
Copy link
Owner

remkop commented Jun 16, 2019

This is a duplicate of #10, but your description is much better, so I’ll keep both tickets open.

Contributions (code snippets, PRs, tests, etc) welcome!

Note to self:

import org.junit.Test;
import static org.junit.Assert.*;

import java.util.*;

public class PrefixTest {
    static List<String> splitIntoChunks(String command) {
        List<String> result = new ArrayList<String>();
        int start = 0;
        for (int i = 0; i < command.length(); i++) {
            int codepoint = command.codePointAt(i);
            if (Character.isUpperCase(codepoint) || '-' == codepoint) {
                String chunk = makeCanonical(command.substring(start, i));
                if (chunk.length() > 0) {
                    result.add(chunk);
                }
                start = i;
            }
        }
        if (start < command.length()) {
            String chunk = makeCanonical(command.substring(start));
            if (chunk.length() > 0) {
                result.add(chunk);
            }
        }
        return result;
    }

    private static String makeCanonical(String str) {
        if ("-".equals(str)) {
            return "";
        }
        if (str.startsWith("-") && str.length() > 1) {
            int codepoint = str.codePointAt(1);
            return ((char) Character.toUpperCase(codepoint)) + str.substring(1 + Character.charCount(codepoint));
        }
        return str;
    }

    static String match(Map<String, String> map, String abbreviation) {
        if (map.containsKey(abbreviation)) {
            return abbreviation;// was: return map.get(abbreviation);
        }
        List<String> abbreviatedKeyChunks = splitIntoChunks(abbreviation);
        List<String> candidates = new ArrayList<String>();
        next_key: for (String key : map.keySet()) {
            List<String> keyChunks = splitIntoChunks(key);
            if (abbreviatedKeyChunks.size() <= keyChunks.size() && keyChunks.get(0).startsWith(abbreviatedKeyChunks.get(0))) { // first chunk must match
                int matchCount = 1;
                int keyChunk = 1;
                for (int i = 1; i < abbreviatedKeyChunks.size(); i++) {
                    boolean found = false;
                    for (int j = keyChunk; j < keyChunks.size(); j++) {
                        if (keyChunks.get(j).startsWith(abbreviatedKeyChunks.get(i))) { // first chunk must match
                            keyChunk = j + 1;
                            found = true;
                            break;
                        }
                    }
                    if (!found) { // not a candidate
                        continue next_key;
                    }
                    matchCount++;
                }
                if (matchCount == abbreviatedKeyChunks.size()) {
                    candidates.add(key);
                }
            }
        }
        if (candidates.size() > 1) {
            String str = candidates.toString();
            throw new IllegalArgumentException(abbreviation + " is not unique: it matches '" + str.substring(1, str.length() - 1).replace(", ", "', '") + "'");
        }
        return candidates.isEmpty() ? null : candidates.get(0);
    }

    private Map<String, String> createMap() {
        Map<String, String> result = new LinkedHashMap<String, String>();
        result.put("kebab-case-extra", "kebab-case-extra");
        result.put("kebab-case-extra-extra", "kebab-case-extra-extra");
        result.put("kebab-case", "kebab-case");
        result.put("kc", "kebab-case"); // alias
        result.put("very-long-kebab-case", "very-long-kebab-case");
        result.put("camelCase", "camelCase");
        result.put("veryLongCamelCase", "veryLongCamelCase");
        return result;
    }

    @Test
    public void testPrefixMatch() {
        Map<String, String> map = createMap();
        
        assertEquals("kebab-case", match(map, "kebab-case"));
        assertEquals("kebab-case-extra", match(map, "kebab-case-extra"));
        assertEquals("very-long-kebab-case", match(map, "very-long-kebab-case"));
        assertEquals("very-long-kebab-case", match(map, "v-l-k-c"));
        assertEquals("very-long-kebab-case", match(map, "vLKC"));
        assertEquals("camelCase", match(map, "camelCase"));
        assertEquals("camelCase", match(map, "cC"));
        assertEquals("camelCase", match(map, "c-c"));
        assertEquals("camelCase", match(map, "camC"));
        assertEquals("veryLongCamelCase", match(map, "veryLongCamelCase"));
        assertEquals("veryLongCamelCase", match(map, "vLCC"));
        assertEquals("veryLongCamelCase", match(map, "v-l-c-c"));

        try {
            match(map, "vLC");
            fail("Expected exception");
        } catch (IllegalArgumentException ex) {
            assertEquals("vLC is not unique: it matches 'very-long-kebab-case', 'veryLongCamelCase'", ex.getMessage());
        }
        try {
            match(map, "k-c");
            fail("Expected exception");
        } catch (IllegalArgumentException ex) {
            assertEquals("k-c is not unique: it matches 'kebab-case-extra', 'kebab-case-extra-extra', 'kebab-case'", ex.getMessage());
        }
        try {
            match(map, "kC");
            fail("Expected exception");
        } catch (IllegalArgumentException ex) {
            assertEquals("kC is not unique: it matches 'kebab-case-extra', 'kebab-case-extra-extra', 'kebab-case'", ex.getMessage());
        }
        try {
            match(map, "keb-ca");
            fail("Expected exception");
        } catch (IllegalArgumentException ex) {
            assertEquals("keb-ca is not unique: it matches 'kebab-case-extra', 'kebab-case-extra-extra', 'kebab-case'", ex.getMessage());
        }
    }

    @Test
    public void testSplitIntoChunks() {
        assertEquals(Arrays.asList("k", "C"), splitIntoChunks("kC"));
        assertEquals(Arrays.asList("k", "C"), splitIntoChunks("k-c"));
        assertEquals(Arrays.asList("kebab", "Case"), splitIntoChunks("kebab-case"));
        assertEquals(Arrays.asList("very", "Long", "Kebab", "Case"), splitIntoChunks("very-long-kebab-case"));
        assertEquals(Arrays.asList("camel", "Case"), splitIntoChunks("camelCase"));
        assertEquals(Arrays.asList("very", "Long", "Camel", "Case"), splitIntoChunks("veryLongCamelCase"));
    }
}

@remkop remkop added this to the 4.1 milestone Jun 16, 2019
@remkop remkop modified the milestones: 4.2, 4.3 Feb 11, 2020
@remkop remkop added the theme: parser An issue or change related to the parser label Apr 2, 2020
@remkop remkop modified the milestones: 4.3, 4.4 Apr 14, 2020
remkop added a commit that referenced this issue May 24, 2020
remkop added a commit that referenced this issue May 24, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
theme: parser An issue or change related to the parser type: enhancement ✨
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants