Java の CLI アプリケーション用フレームワーク picocli はミスタイプ時にサジェスチョンを出してくれる

長くなっちゃったから最初にまとめ

まとめ

picocli は便利。

デフォルトでサジェスチョンの機能がついている。なので、オプションやサブコマンドの定義だけしておけば、ミスタイプしたときにサジェスチョンを出してくれる。

  • オプションの場合は、先頭2文字が一致するオプション一覧
  • サブコマンドの場合は、先頭2文字じゃなくて、似たものを出してくれる

こんなつぶやきを見かけて

がくぞさんのこんなつぶやきを見かけて

あぁ、たしかにそういうのフレームワークに含まれてたら便利だなー、picocli だったらありそうだけどどうなんだろうなぁ?って興味本位で見てみたら、デフォルトでその機能が入ってたのでメモを残しとく。

picocli?

Javaコマンドラインアプリケーションを作る用のフレームワーク

https://picocli.info/

最初ピコリかー名前かわいいなと思ってたけどよく見てみるとピコシーエルアイだった。いろいろと気が利いていて便利そうだなぁって思って眺めてる。ちゃんと使って何かを作ったことはまだない。GraalVM の NativeImage に対応してるのも良いね。

サンプルアプリ

↓に書いてある例をそのままコピペして作った

https://picocli.info/#_example_application

まるっとそのままってのもなぁと思って bufferings ってオプションを足しておいた。何の役割もない。

@Command(name = "checksum", mixinStandardHelpOptions = true, version = "checksum 4.0",
    description = "Prints the checksum (MD5 by default) of a file to STDOUT.")
public class CheckSum implements Callable<Integer> {

  @Parameters(index = "0", description = "The file whose checksum to calculate.")
  private File file;

  @Option(names = {"-a", "--algorithm"}, description = "MD5, SHA-1, SHA-256, ...")
  private String algorithm = "MD5";

  @Option(names = {"-b", "--bufferings"}, description = "Sample.")
  private String bufferings = "MD5";

  @Override
  public Integer call() throws Exception { // your business logic goes here...
    byte[] fileContents = Files.readAllBytes(file.toPath());
    byte[] digest = MessageDigest.getInstance(algorithm).digest(fileContents);
    System.out.printf("%0" + (digest.length * 2) + "x%n", new BigInteger(1, digest));
    return 0;
  }

  // this example implements Callable, so parsing, error handling and handling user
  // requests for usage help or version help can be done with one line of code.
  public static void main(String... args) {
    int exitCode = new CommandLine(new CheckSum()).execute(args);
    System.exit(exitCode);
  }
}

ここに置いといた:

github.com

試してみると

↑のようにオプションを定義しておくだけで、ミスタイプしたときは picocli が勝手に「これじゃない?」って教えてくれる。

気が利くなぁ

公式ドキュメント

https://picocli.info/#_invalid_user_input

The default parameter exception handler prints an error message describing the problem, followed by either suggested alternatives for mistyped options, or the full usage help message of the problematic command. Finally, the handler returns an exit code. This is sufficient for most applications.

「パラメーターに対するデフォルトの例外ハンドラーは、問題の内容をプリントしたあとに、ミスタイプしたオプションの候補かヘルプを表示する」ってちゃんと書いてある。

最初にこれを読んで、これっぽいよなぁと思って。これが僕が思ってるのと同じことなのかなぁ?って確かめてみることにしたのだった。合ってた。

でもどこで?

ソースを見てみたら CommandLine.java のこの部分でサジェスチョンを取得してる。この戻り値のリストが空じゃなかったら、それが候補として表示されて、空だったら、ヘルプが表示される。

/** Returns suggested solutions if such solutions exist, otherwise returns an empty list.
    * @since 3.3.0 */
public List<String> getSuggestions() {
    if (unmatched.isEmpty()) { return Collections.emptyList(); }
    String arg = unmatched.get(0);
    String stripped = CommandSpec.stripPrefix(arg);
    CommandSpec spec = getCommandLine().getCommandSpec();
    if (spec.resemblesOption(arg, null)) {
        return spec.findVisibleOptionNamesWithPrefix(stripped.substring(0, Math.min(2, stripped.length())));
    } else if (!spec.subcommands().isEmpty()) {
        List<String> visibleSubs = new ArrayList<String>();
        for (Map.Entry<String, CommandLine> entry : spec.subcommands().entrySet()) {
            if (!entry.getValue().getCommandSpec().usageMessage().hidden()) { visibleSubs.add(entry.getKey()); }
        }
        List<String> mostSimilar = CosineSimilarity.mostSimilar(arg, visibleSubs);
        return mostSimilar.subList(0, Math.min(3, mostSimilar.size()));
    }
    return Collections.emptyList();
}

L.18029 にある (picocli:4.6.1)。CommandLine.java はファイルが大きすぎてGithub で表示できないみたい↓w

https://github.com/remkop/picocli/blob/master/src/main/java/picocli/CommandLine.java#18029

ざっと読んでみよう

まずは resemblesOption で「オプションっぽいかどうか」をチェックしてる。Possible solutions: が表示されるためには、これが true になる必要がある。

boolean resemblesOption(String arg, Tracer tracer) {
    if (arg == null) { return false; }
    if (arg.length() == 1) {
        if (tracer != null && tracer.isDebug()) {tracer.debug("Single-character arguments that don't match known options are considered positional parameters%n", arg);}
        return false;
    }
    try { Long.decode(arg);        return false; } catch (NumberFormatException nan) {} // negative numbers are not unknown options
    try { Double.parseDouble(arg); return false; } catch (NumberFormatException nan) {} // negative numbers are not unknown options

    if (options().isEmpty()) {
        boolean result = arg.startsWith("-");
        if (tracer != null && tracer.isDebug()) {tracer.debug("'%s' %s an option%n", arg, (result ? "resembles" : "doesn't resemble"));}
        return result;
    }
    int count = 0;
    for (String optionName : optionsMap().keySet()) {
        for (int i = 0; i < arg.length(); i++) {
            if (optionName.length() > i && arg.charAt(i) == optionName.charAt(i)) { count++; } else { break; }
        }
    }
    boolean result = count > 0 && count * 10 >= optionsMap().size() * 9; // at least one prefix char in common with 9 out of 10 options
    if (tracer != null && tracer.isDebug()) {tracer.debug("'%s' %s an option: %d matching prefix chars out of %d option names%n", arg, (result ? "resembles" : "doesn't resemble"), count, optionsMap().size());}
    return result;
}

↓ここがいまいち分かんないんだけど、対象の文字列と、このコマンドのオプション全部に対して前方一致する文字数を取得して、一致する文字数がオプションの数の9割を超えてたらOKになるみたい。大体のオプションは --- で始まると思うから、それで始まってたら true になりそう。ダッシュで始まってないものは「オプションっぽくない!」って判別されるってことなのかな。

boolean result = count > 0 && count * 10 >= optionsMap().size() * 9; // at least one prefix char in common with 9 out of 10 options

その次は?

resemblesOptiontrue だったら、次は、この処理に入る。ここで返されたリストが空じゃなければ Possible solutions: として表示されて、空だったら usage が表示される。

  return spec.findVisibleOptionNamesWithPrefix(stripped.substring(0, Math.min(2, stripped.length())));

おや?ダッシュを取り除いて、最初の2文字だけが渡されるっぽいぞ?

List<String> findVisibleOptionNamesWithPrefix(String prefix) {
    List<String> result = new ArrayList<String>();
    for (OptionSpec option : options()) {
        for (String name : option.names()) {
            if (!option.hidden() && stripPrefix(name).startsWith(prefix)) { result.add(name); }
        }
    }
    return result;
}

ふむ。最初の2文字の前方一致するオプションを表示してるってことか。想像してたよりシンプルだな。

ところで

こんな Issue を見つけた

Add help for mistyped commands · Issue #298 · remkop/picocli · GitHub

あれ?最初の2文字よりももっとリッチな感じがするぞ。

ので

もうちょっとコードを眺めてみる。あー。getSuggestions のオプションじゃなくてサブコマンドの方か

        List<String> visibleSubs = new ArrayList<String>();
        for (Map.Entry<String, CommandLine> entry : spec.subcommands().entrySet()) {
            if (!entry.getValue().getCommandSpec().usageMessage().hidden()) { visibleSubs.add(entry.getKey()); }
        }
        List<String> mostSimilar = CosineSimilarity.mostSimilar(arg, visibleSubs);
        return mostSimilar.subList(0, Math.min(3, mostSimilar.size()));

resemblesOption でオプションっぽくないって判断されたときにこっちに入ってきて、似てるサブコマンドの上位3つを返してる。ほほー。

CosineSimilarity.mostSimilar

CosineSimilarity というクラスでスコアを計算してるみたいで、このクラスは ↑の Github Issue にコメントにある通り Grails のコードを参考にしたみたいね

Grails のコード:

grails-core/CosineSimilarity.groovy at master · grails/grails-core · GitHub

ふむふむ。picocli の CommandLine は、これを参考にして内部に private な CosineSimilarity クラスを定義してて、その中で bigram を使って似てるかどうかを判断してるみたい。

だから、サブコマンドの場合は前方一致じゃなくてもサジェスチョンを出してくれる:

Unmatched arguments from index 0: 'mmit', '--bufferings=abcde'
Did you mean: commit?

へー。なんでオプションは前方一致で、サブコマンドはそうじゃないんだろう?

ためしに

オプションも CosineSimilarity を使うように強引にごにょごにょしてみると

Unknown options: '--uff', 'abcde'
Possible solutions: --bufferings

ってできた。うーん。でも結構めんどくさかったし、別にオプションは先頭2文字で十分な気がする。

まとめ

picocli は便利。

デフォルトでサジェスチョンの機能がついている。なので、オプションやサブコマンドの定義だけしておけば、ミスタイプしたときにサジェスチョンを出してくれる。

  • オプションの場合は、先頭2文字が一致するオプション一覧
  • サブコマンドの場合は、最初の2文字じゃなくて、似たものを出してくれる

おまけ

この記事を書くのに、動作を確認しながら書いたので、push しといた。

面白かった