🦔

JavaのJLineで高機能CLIを作る

2023/05/28に公開

はじめに

大学の課題で Java で何かしらのアプリケーションを作れ、という課題があり CLI で作ることになったのでリッチな CLI を作る方法を探していました。
そして JLine というコンソールの入力を扱うためのライブラリに出会ったのでその使い方をまとめます。

最終的にできあがるもの

Maven を使って実行しているためコンソールにメタ的な情報が入ります。
後述する maven-assembly-plugin を使えば実行可能 jar ファイルを出力することが可能です。
jline

環境

  • Maven
  • Java JDK 17

環境構築

  1. Maven Project を作成
    1. VSCode なら Ctrl + Shift + P
    2. java: Create Java Project
    3. maven-archetype-quickstart
    4. 1.4(2023-05-28 現在)
    5. グループ ID を適当に入力(ここでは com.example
    6. artifact ID を適当に入力(ここでは demo
    7. ターミナルが出てきて入力を求められたらエンターキー連打でいいです
  2. JLine を追加
    1. Ctrl + Shift + P
    2. maven: add a dependency
    3. jline
    4. org.jline の jline を追加(2023-05-28)の時点でバージョンは 3.23.0

こうなったら OK です。
テストの方は今回触らないため消していただいても OK です。

ディレクトリ構成
demo
├ src
│ ├ main/java/com/example
│ │ └ App.java
│ └ test/java/com/example
│   └ AppTest.java
├ target/ # (ビルド済みファイル)
└ pom.xml
pom.xml
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>1.0-SNAPSHOT</version>

<name>demo</name>

  <!-- FIXME change it to the project's website -->

<url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.7</maven.compiler.source>
    <maven.compiler.target>1.7</maven.compiler.target>
  </properties>

  <dependencies>

    <dependency>
      <groupId>org.jline</groupId>
      <artifactId>jline</artifactId>
      <version>3.23.0</version>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>

  </dependencies>

  <build>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle -->
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-jar-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
        <!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
        <plugin>
          <artifactId>maven-site-plugin</artifactId>
          <version>3.7.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-project-info-reports-plugin</artifactId>
          <version>3.0.0</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

TAB キーによる補完の実装

ファイルを 1 つ追加します。

 demo
 ├ src
 │ ├ main/java/com/example
+│ │ ├ UtilizedTerminal.java
 │ │ └ App.java
 │ └ test/java/com/example
 │   └ AppTest.java
 ├ target/ # (ビルド済みファイル)
 └ pom.xml
UtilizedTerminal.java
package com.example;

import java.io.IOException;
import java.util.*;

import org.jline.builtins.Completers.TreeCompleter;
import org.jline.console.CmdDesc;
import org.jline.reader.*;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.*;

public class UtilizedTerminal {

  public static LineReader initializeTerminal() {
    // ハイライトや解析の設定
    // デフォルトのものを使用
    Parser parser = new DefaultParser();

    // 補完の設定
    Completer completer = createCompleter();
    try (Terminal terminal = TerminalBuilder.terminal()) {
      LineReader reader = LineReaderBuilder.builder().terminal(terminal).completer(completer).parser(parser)
          .build();
      showMessages();

      return reader;
    } catch (IOException e) {
      e.printStackTrace();
    }
    return null;
  }

  private static Completer createCompleter() {
    Completer completer = new TreeCompleter(TreeCompleter.node("hoge", TreeCompleter.node("--fuga", "--bar")));

    return completer;
  }

  private static void showMessages() {
    System.out.println("次のコマンドを打ってください");
    System.out.println("TABキーも使えます");
    System.out.println("コマンド: hoge");
  }
}

App.java
package com.example;

import org.jline.reader.LineReader;
import org.jline.reader.UserInterruptException;

public class App {
  public static void main(String[] args) {
    LineReader reader = UtilizedTerminal.initializeTerminal();

    try {
      while (true) {
        String line = reader.readLine("===> ");
        System.out.println(line);
      }
    } catch (UserInterruptException _) {
      System.out.println("処理が中断されました");
    }
  }
}

実行方法

# com.example パッケージの App クラスの main メソッドを動作
mvn exec:java -Dexec.mainClass=com.example.App

現在Appクラスは入力をそのまま返すだけです。

流れを説明すると、入力を読み取るのにTerminalオブジェクトとParserオブジェクトと、Completerオブジェクトを設定します。
それを ↓ のようにすることで入力を読み取ることができます。
readLineメソッドの引数 ===> はプロンプトを表示したときに出てくるものです。
好きなものを表示してください。

LineReader reader = LineReaderBuilder.builder().terminal(terminal).completer(completer).parser(parser)
    .build();
// 入力された文字
String line = reader.readLine("===> ");

必須なのはTerminalオブジェクトだけです。

  Terminal terminal = ...;
  LineReader lineReader = LineReaderBuilder.builder()
                              .terminal(terminal)
                              .build();

実行するとこのようになります。
jline(補完のみ)

補完機能を編集

補完の CLI を少し複雑にしてみましょう。

今まではこのコマンドだけでした。

$ hoge --fuga
$ hoge --bar

これを補完機能の説明のために、このようなものが可能になるように変更します。

$ hoge --fuga foo
$ hoge --bar baz
UtilizedTerminal.java
 package com.example;

 import java.io.IOException;

 import org.jline.builtins.Completers.TreeCompleter;
 import org.jline.reader.*;
 import org.jline.reader.impl.DefaultParser;
 import org.jline.terminal.*;

 public class UtilizedTerminal {

   public static LineReader initializeTerminal() {
     Parser parser = new DefaultParser();
     Completer completer = createCompleter();
     try (Terminal terminal = TerminalBuilder.terminal()) {
       LineReader reader = LineReaderBuilder.builder().terminal(terminal).completer(completer).parser(parser)
           .build();
       showMessages();

       return reader;
     } catch (IOException e) {
       e.printStackTrace();
     }
     return null;
   }

   private static Completer createCompleter() {
-    Completer completer = new TreeCompleter(TreeCompleter.node("hoge", TreeCompleter.node("--fuga", "--bar")));
+     Completer completer = new TreeCompleter(
+         TreeCompleter.node(
+             "hoge",
+             TreeCompleter.node("--fuga", TreeCompleter.node("foo")),
+             TreeCompleter.node("--bar", TreeCompleter.node("baz"))));

     return completer;
   }

   private static void showMessages() {
     System.out.println("次のコマンドを打ってください");
     System.out.println("TABキーも使えます");
     System.out.println("コマンド: hoge");
   }
 }

やっていることは分かりやすいと思います。

一応説明します。
今回hoge ノードの下には--fugaノードと--barノードがあります。
--fugaノードにはfooノード、--barノードにはbazノードがあります。

文法としてはつまりこういうことです。

TreeCompleter.node("ノード名", "子ノード")

子ノードにはさらに子ノード(孫ノード)があってその下にはさらに子ノードが...ということです。

新たに足したいと思ったら、ここに付け足せばよいのですね。

リファレンスを見ていて詰まったところ

この TreeComplter(木構造による補完)の部分のサンプルソースコードは次のように書かれていました。

completer = new TreeCompleter(
    node("Command1",
        node("Option1",
            node("Param1", "Param2")),
        node("Option2"),
        node("Option3")));

ですがこの通りにやると
The method node(String, Completers.TreeCompleter.Node) is undefined for the type UtilizedTerminal(今回のソースコードのクラス名)
のようなエラーが出ます。
このエラーの通り、nodeはそのクラスのメソッドとして判定されてしまいます。
正しくはTreeCompleterのメソッドなので、

TreeCompleter.node("ノード名");

とする必要があります。
ちょっと考えればわかることでしょうが、Java に慣れていない僕は騙されました。

補完について

補完には色々なものが使えます。

  • 木構造による補完(今回使用)
  • 正規表現による補完
  • ファイルパスをサジェストすることによる補完

CLI を作るにあたり、最初は正規表現を使用していました。
ですが正規表現だとソースコードの見栄えがとても悪かったです。
それと思ったように機能しなかったため、木構造を使用しました。
正規表現については後述します。

正規表現による補完

補完にも色々あります。
正規表現だけ紹介します。

Map<String, Completer> comp = new HashMap<>();
comp.put("C1", new StringsCompleter("cmd1"));
comp.put("C11", new StringsCompleter("--opt11", "--opt12"));
comp.put("C12", new StringsCompleter("arg11", "arg12", "arg13"));
comp.put("C2", new StringsCompleter("cmd2"));
comp.put("C21", new StringsCompleter("--opt21", "--opt22"));
comp.put("C22", new StringsCompleter("arg21", "arg22", "arg23"));
completer = new Completers.RegexCompleter("C1 C11* C12+ | C2 C21* C22+", comp::get);

自動サジェスト

今までは TAB キーを押すと選択肢が現れる、というものでした。
これをタイプしていると自動で候補が表示されるというように変更します。

UtilizedTerminal.java
 package com.example;

 import java.io.IOException;

 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;

 import org.jline.builtins.Completers.TreeCompleter;
 import org.jline.console.ArgDesc;
 import org.jline.console.CmdDesc;
 import org.jline.reader.*;
 import org.jline.reader.impl.DefaultParser;
 import org.jline.terminal.*;
 import org.jline.utils.AttributedString;
 import org.jline.utils.AttributedStyle;
 import org.jline.widget.TailTipWidgets;
 import org.jline.widget.TailTipWidgets.TipType;

 public class UtilizedTerminal {

   public static LineReader initializeTerminal() {
     Parser parser = new DefaultParser();
     Completer completer = createCompleter();
     try (Terminal terminal = TerminalBuilder.terminal()) {
       LineReader reader = LineReaderBuilder.builder().terminal(terminal).completer(completer).parser(parser)
           .build();
       showMessages();

+    // コマンド、コマンドの説明のマップ
+      // 説明の部分にオプションの説明を加える。
+      Map<String, CmdDesc> tailTips = new HashMap<>();
+      // hogeコマンドのオプション:ウィジェットに現れる部分の設定
+      Map<String, List<AttributedString>> hogeWidgetOpts = new HashMap<>();
+      // --fuga オプションの説明
+      hogeWidgetOpts.put("--fuga", Arrays.asList(new AttributedString("ふがの説明文")));
+      // --bar オプションの説明
+      hogeWidgetOpts.put("--bar", Arrays.asList(new AttributedString("ばーの説明文")));
+
+      // hoge コマンド自体の説明
+      // 文字に色を付けて表示
+      List<AttributedString> hogeMainDesc = Arrays
+          .asList(new AttributedString("hogeコマンドの説明文", AttributedStyle.DEFAULT.foreground(AttributedStyle.BRIGHT)));
+
+      tailTips.put("hoge",
+          new CmdDesc(hogeMainDesc, ArgDesc.doArgNames(Arrays.asList("")), hogeWidgetOpts));
+
+      // 補完の最大サジェスト数は5
+      TailTipWidgets tailtipHogeWidgets = new TailTipWidgets(reader, tailTips, 5, TipType.COMPLETER);
+       // 自動サジェスト機能の有効化
+      tailtipHogeWidgets.enable();
      return reader;
    } catch (IOException e) {
      e.printStackTrace();
    }
    return null;
  }

  private static Completer createCompleter() {
    Completer completer = new TreeCompleter(
        TreeCompleter.node(
            "hoge",
            TreeCompleter.node("--fuga", TreeCompleter.node("foo")),
            TreeCompleter.node("--bar", TreeCompleter.node("baz"))));

    return completer;
  }

  private static void showMessages() {
    System.out.println("次のコマンドを打ってください");
    System.out.println("TABキーも使えます");
    System.out.println("コマンド: hoge");
  }
}

やっていることがややこしく感じますね。
大まかに抜き出すとこのようになります。

// 補完の最大サジェスト数は5
TailTipWidgets tailtipHogeWidgets = new TailTipWidgets(
  コンソールの入力,
  コマンドに対してその説明,
  最大表示数,
  表示方法のオプション);

 // 自動サジェスト機能の有効化
tailtipHogeWidgets.enable();

簡単に言うと、このコマンドに対してその説明という引数部分に、コマンドとそのコマンドに対する説明のマッピングを充てるだけです。

ここだけ見ると簡単ですが、メイン部分はややこしく見えますね。
なぜややこしく見えるのかというと、コマンドに対する説明というマッピングの Value 部分に、さらにオプションに対する説明のマッピングが付け加えられているからだと思います。(ここでは hogeWidgetOpts)
ここを分離すると単純明快になると思います。

文字の色の変更

ここでhogeコマンドの説明文という文字に色を付けて表示するようにしています。
AttributedStringクラスは JLine のクラスの 1 つです。
このように表示する色を変更するのに使用できます。

// hoge コマンド自体の説明
// 文字に色を付けて表示
List<AttributedString> hogeMainDesc = Arrays
    .asList(new AttributedString("hogeコマンドの説明文", AttributedStyle.DEFAULT.foreground(AttributedStyle.BRIGHT)));

インラインサジェスト

インラインサジェストとは、カーソル後ろに候補をインラインでサジェストすることです。

JLine にこの機能のビシッとした名前はなく、ドキュメントには「カーソルの後ろの引数のサジェスト」と書いてありました。
ややこしいのでインラインサジェストと仮称することにします。

これの実現は、以下のように変更するだけです。

UtilizedTerminal.java
 package com.example.UtilizedTerminal;

 import java.io.IOException;

 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;

 import org.jline.builtins.Completers.TreeCompleter;
 import org.jline.console.ArgDesc;
 import org.jline.console.CmdDesc;
 import org.jline.reader.*;
 import org.jline.reader.impl.DefaultParser;
 import org.jline.terminal.*;
 import org.jline.utils.AttributedString;
 import org.jline.utils.AttributedStyle;
 import org.jline.widget.TailTipWidgets;
 import org.jline.widget.TailTipWidgets.TipType;

 public class UtilizedTerminal {

   public static LineReader initializeTerminal() {
     Parser parser = new DefaultParser();
     Completer completer = createCompleter();
     try (Terminal terminal = TerminalBuilder.terminal()) {
       LineReader reader = LineReaderBuilder.builder().terminal(terminal).completer(completer).parser(parser)
           .build();
       showMessages();

       Map<String, CmdDesc> tailTips = new HashMap<>();
       Map<String, List<AttributedString>> hogeWidgetOpts = new HashMap<>();
       hogeWidgetOpts.put("--fuga", Arrays.asList(new AttributedString("ふがの説明文")));
       hogeWidgetOpts.put("--bar", Arrays.asList(new AttributedString("ばーの説明文")));

       List<AttributedString> hogeMainDesc = Arrays
           .asList(new AttributedString("hogeコマンドの説明文", AttributedStyle.DEFAULT.foreground(AttributedStyle.BRIGHT)));

-      tailTips.put("hoge", new CmdDesc(hogeMainDesc, ArgDesc.doArgNames(Arrays.asList("")), hogeWidgetOpts));
+      tailTips.put("hoge", new CmdDesc(hogeMainDesc, ArgDesc.doArgNames(Arrays.asList("[pN...]")), hogeWidgetOpts));
      TailTipWidgets tailtipHogeWidgets = new TailTipWidgets(reader, tailTips, 5, TipType.COMPLETER);
      tailtipHogeWidgets.enable();
      return reader;
    } catch (IOException e) {
      e.printStackTrace();
    }
    return null;
  }

  private static Completer createCompleter() {
    Completer completer = new TreeCompleter(
        TreeCompleter.node(
            "hoge",
            TreeCompleter.node("--fuga", TreeCompleter.node("foo")),
            TreeCompleter.node("--bar", TreeCompleter.node("baz"))));

    return completer;
  }

  private static void showMessages() {
    System.out.println("次のコマンドを打ってください");
    System.out.println("TABキーも使えます");
    System.out.println("コマンド: hoge");
  }
}

こうすることで、hogeコマンドを入力している間はインラインに[pN...]という文字列が表示されます。

ちなみに、オプションごとに表示される文字を変更したかったのですが、できませんでした。
このデモのようにやりたかったのですが苦闘の末、敗北しました。
もしご存知の方は教えてくださると幸いです。

最終的にはこのようになります。
jline

表示のカスタマイズ

サジェストの方法を 3 種類設定できます。

  1. COMPLETER

    • タブ補完を表示
    • カーソル後ろのサジェストを非表示
  2. TAIL_TIP

    • タブ補完を非表示
    • カーソル後ろのサジェストを表示
  3. COMBINED

    • タブ補完、カーソル後ろのサジェスト両方を表示

TailTipWidgets tailtipWidgets = new TailTipWidgets(reader, tailTips, 0, TipType.TAIL_TIP);
TailTipWidgets tailtipWidgets = new TailTipWidgets(reader, tailTips, 0, TipType.COMPLETER);
実行可能 jar ファイルを作成する方法

maven-assembly-plugin を追加します。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>demo</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>demo</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.7</maven.compiler.source>
    <maven.compiler.target>1.7</maven.compiler.target>
  </properties>

  <dependencies>

    <dependency>
      <groupId>org.jline</groupId>
      <artifactId>jline</artifactId>
      <version>3.23.0</version>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-assembly-plugin</artifactId>
+        <version>3.5.0</version>
+        <configuration>
+          <archive>
+            <manifest>
+              <mainClass>com.example.App</mainClass>
+            </manifest>
+          </archive>
+          <descriptorRefs>
+            <descriptorRef>jar-with-dependencies</descriptorRef>
+          </descriptorRefs>
+        </configuration>
+        <executions>
+          <execution>
+            <id>make-assembly</id>
+            <phase>package</phase>
+            <goals>
+              <goal>single</goal>
+            </goals>
+          </execution>
+        </executions>
+        </plugin>
+    </plugins>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle -->
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-jar-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
        <!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
        <plugin>
          <artifactId>maven-site-plugin</artifactId>
          <version>3.7.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-project-info-reports-plugin</artifactId>
          <version>3.0.0</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

次のコマンドでビルドします。

mvn clean package

そうするとtarget/のなかにdemo-1.0-SNAPSHOT-jar-with-dependencies.jarファイルができます。

これにパスを通して実行することができます。

# 例
java -jar target/demo-1.0-SNAPSHOT-jar-with-dependencies.jar

終わり

使った感想ですが、プログラムから Zsh の補完機能のようなものを作成できるというのは面白かったです。

参考文献少なすぎて苦労したため、同じような苦労をする人がいないように一応まとめました。
特にバーション 3 のドキュメント少なすぎです。
Wiki ですら満足してなかったのはまじつらたん。

今どき Java でさらに CLI だなんて一体誰が使うのでしょうか。
でも誰かのお役に立てられたなら幸いです。

ぜひ大学の課題にでも使って身内にちょっとだけ自慢してください。

参考

GitHubで編集を提案

Discussion