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

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

まとめ

picocli は便利。

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

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

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

そういえばCLIのオプションパーザのライブラリは多種あるけど、定義されてないオプションが指定されたときにオプション名から類推して正しくはコレじゃない?ってサジェストしてくれるような機構まで盛り込んだライブラリってあるのかな?

— がくぞ (@gakuzzzz) August 11, 2021

あぁ、たしかにそういうのフレームワークに含まれてたら便利だなー、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 {

@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 { 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; }

public static void main(String... args) { int exitCode = new CommandLine(new CheckSum()).execute(args); System.exit(exitCode); } }

ここに置いといた:

github.com

試してみると

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

へー。あった。 pic.twitter.com/IAiqDKKt3N

— Mitsuyuki Shiiba (@bufferings) August 11, 2021

気が利くなぁ

公式ドキュメント

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 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 visibleSubs = new ArrayList(); for (Map.Entry<String, CommandLine> entry : spec.subcommands().entrySet()) { if (!entry.getValue().getCommandSpec().usageMessage().hidden()) { visibleSubs.add(entry.getKey()); } } List 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) {} try { Double.parseDouble(arg); return false; } catch (NumberFormatException nan) {}

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; 
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;

その次は?

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

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

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

List findVisibleOptionNamesWithPrefix(String prefix) { List result = new ArrayList(); 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 は便利。

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

おまけ

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

面白かった