Java 命令行交互輸入庫 JLine 入門

咱們都知道,軟件的用戶界面無非分爲 GUI (圖形用戶界面)和 CLI (命令行用戶界面)。對於咱們常用 Linux 的人來講,命令行界面必定很是熟悉。不管是 Shell 裏輸入命令的界面,仍是如 GDB 等軟件的內部交互界面,都是命令行界面。而當咱們開發本身的軟件,要寫認真寫一個 CLI 的時候,卻發現要手寫作出一個好用的命令行界面其實很是困難。由於一個好的命令行界面,在輸入/輸出以外,還要支持一些常見的命令行功能。java

對我而言,一個合格的命令行軟件界面應該支持這三個功能:git

  • 自動補全:當按下 TAB 鍵時,在當前光標處進行內容補全。根據上下文信息,補全多是對命令的補全,也多是對文件路徑的補全。
  • 命令歷史:當按上/下方向鍵時,能夠顯示上一條/下一條命令。
  • 行編輯 (line editing):可使用 Emacs 快捷鍵進行行內的編輯功能,例如 Ctrl+A 移動光標至行首,Ctrl+E 移動光標至行尾。

熟悉 Linux 的人會發現,上面這三個功能都是 GNU Readline 的功能。咱們不須要在軟件中手寫這幾個功能,只要用這樣一個庫就能夠了。實際上,GNU/Linux 中使用 GNU Readline 庫的軟件很是多,這使得 GNU Readline 同時也成爲了一個事實上的命令行交互標準。GNU Readline 是 C 語言的庫。咱們用其餘語言的時候,就要找對應功能的庫(這每每是封裝了底層的 GNU Readline 的庫)。對 Java 語言來講,JLine 就是這樣一個幫助你搭建一個命令行交互界面的庫。github

本文是想經過一個例子介紹 JLine3 的基本用法。JLine3 並無一個 "Hello, world!" 的例子,它的 wiki 也寫得很是簡略。雖然有一個示例的程序 Example.java,但這個示例比較複雜,難以理解。但願本文的內容能對你理解 JLine3 的用法有所幫助。bash

基本框架

咱們嘗試爲軟件 Fog 設計一個命令行用戶界面。用戶能夠輸入四種命令:框架

CREATE [FILE_NAME]
OPEN [FILE_NAME] AS [FILE_VAR]
WRITE TIME|DATE|LOCATION TO [FILE_VAR]
CLOSE [FILE_VAR]
複製代碼

下面咱們將一步步地寫出 Fog 軟件的命令行界面。首先,用 JLine3 搭建一個最基礎的 REPL (Read-Eval-Print Loop) 框架:ide

Terminal terminal = TerminalBuilder.builder()
        .system(true)
        .build();

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

String prompt = "fog> ";
while (true) {
    String line;
    try {
        line = lineReader.readLine(prompt);
        System.out.println(line);
    } catch (UserInterruptException e) {
        // Do nothing
    } catch (EndOfFileException e) {
        System.out.println("\nBye.");
        return;
    }
}
複製代碼

這裏除了設置命令提示符 (prompt),沒有進行任何特殊的設置。命令行會將用戶輸入的一行原樣打印出來。當用戶輸入 Ctrl+D (End of line) 時,程序會退出。oop

即便咱們只寫了一個框架,但此時程序已經擁有了 JLine3 默認提供的命令歷史和行編輯功能。此時按上/下方向鍵時,會顯示上一條/下一條命令,也可使用 Ctrl+A、Ctrl+E 等 Emacs 快捷鍵進行行內編輯。ui

命令補全

簡單補全與複合補全

因爲命令補全和程序的命令格式密切相關,因此咱們必須本身定義補全的方式。根據 wiki 中所寫,JLine3 中定義命令補全的方式是:建立一個 Completer 類的實例,將其傳入 LineReader。JLine3 內置了多個 completer,其中最多見的是 FileNameCompleter (補全文件名)和 StringsCompleter (根據預約義的幾個字符串進行補全,用於命令名或參數名)。例如,Fog 程序的四個命令分別以 CREATE, OPEN, WRITE, CLOSE 開頭,那麼咱們可使用一個 StringsCompleter 來對命令的第一個單詞進行補全:this

Completer commandCompleter = new StringsCompleter("CREATE", "OPEN", "WRITE", "CLOSE");

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(commandCompleter)
        .build();
複製代碼

然而,這種補全方式只能支持每一個命令的第一個單詞,咱們想要在命令的各類可能的地方都進行補全該怎麼辦呢?這時候就須要將 completer 進行組合,造成 複合 completer 。通常狀況下,StringsCompleter 這樣的 簡單 completer 只能負責一個單詞的補全,而要想實現整條命令的補全,就須要將幾個不一樣的 completer 組合起來使用。ArgumentCompleter 就是用來補全整條命令的複合 completer。它能夠將若干個 completer 組合在一塊兒,每一個 completer 負責補全命令中的第 i 個單詞。以 CREATE 命令爲例,這條命令共有兩個單詞,第一個單詞須要字符串補全,第二個單詞須要文件名補全。因而咱們使用 ArgumentCompleterStringsCompleterFileNameCompleter 組合起來:spa

Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter()
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(createCompleter)
        .build();
複製代碼

根據 ArgumentCompleter 的兩個參數,在輸入第一個單詞的時候會補全 CREATE,輸入第二個單詞的時候會補全文件名。但實測時會發現一個問題:當你已經輸入了 CREATE 和文件名後,再試圖進行補全,在第三個單詞處試圖補全,仍是會出現文件名的補全。這是由於,ArgumentCompleter 在你已經「用完了」全部的 completers 以後(即第三個單詞開始),會默認使用最後一個 completer。這並非咱們想要的效果。爲了解決這個問題,咱們能夠在最後添加一個 NullCompleter

Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter(),
        NullCompleter.INSTANCE
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(createCompleter)
        .build();
複製代碼

NullCompleter 即不進行任何補全。這樣,從第三個單詞開始,都不會進行任何多餘的補全。

相似地,咱們再加入 OPEN 命令補全的定義:

Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter(),
        NullCompleter.INSTANCE
);

Completer openCompleter = new ArgumentCompleter(
        new StringsCompleter("OPEN"),
        new Completers.FileNameCompleter(),
        new StringsCompleter("AS"),
        NullCompleter.INSTANCE
);

Completer fogCompleter = new AggregateCompleter(
        createCompleter,
        openCompleter
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(fogCompleter)
        .build();
複製代碼

這裏有兩點須要注意的地方:

  1. CREATE 命令和 OPEN 命令分別定義了 completer,再用 AggregateCompleter 組合起來。AggregateCompleter 是另外一種複合 completer,將多種可能的補全方式組合到了一塊兒。打比方來講,ArgumentCompleter 至關於串聯電路,而 AggregateCompleter 至關於並聯電路。
  2. OPEN 命令的 ArgumentCompleter 中只定義了前三個單詞的補全方式。這是由於第四個單詞是用戶定義了文件變量,用戶可能輸入任何的名字,所以沒法進行補全。

動態補全

WRITE 命令的補全與前兩個稍有不一樣。根據程序語義,只有用戶在 OPEN 命令中定義了的文件變量才能在 WRITE 命令中使用。那麼,在補全的時候也應該考慮這一點。咱們須要在運行時動態地調整補全候選詞:每當用戶使用 OPEN 命令打開一個文件後,都調整 completer,將新的文件變量歸入補全候選詞。咱們須要知道如何動態地修改 completer。雖然 completer 的建立和傳遞給 LineReader 的過程是靜態的,但在程序運行時,是經過調用 Completer.complete() 來獲取補全的候選詞的。那麼,咱們能夠繼承 Completer 並重寫 complete() 方法來實現動態的候選詞調整。

public class FileVarsCompleter implements Completer {

    Completer completer;

    public FileVarsCompleter() {
        this.completer = new StringsCompleter();
    }

    @Override
    public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
        completer.complete(reader, line, candidates);
    }

    public void setFileVars(List<String> fileVars) {
        this.completer = new StringsCompleter(fileVars);
    }
}
複製代碼

當調用 setFileVars() 時,會從新建立一個新的 StringsCompleter,從而擴充候選詞。而在 REPL 中,只須要在用戶輸入 OPEN 命令後,調用 setFileVars() 便可。

public class Fog {

    private static List<String> fileVars = new ArrayList<>();
    private static FileVarsCompleter fileVarsCompleter = new FileVarsCompleter();

    public static void main(String[] args) throws IOException {

        // ...

        Completer writeCompleter = new ArgumentCompleter(
                new StringsCompleter("WRITE"),
                new StringsCompleter("TIME", "DATE", "LOCATION"),
                new StringsCompleter("TO"),
                fileVarsCompleter,
                NullCompleter.INSTANCE
        );

        Completer fogCompleter = new AggregateCompleter(
                createCompleter,
                openCompleter,
                writeCompleter
        );

        // ...

        String prompt = "fog> ";
        while (true) {
            String line;
            try {
                line = lineReader.readLine(prompt);
                System.out.println(line);
                if (line.startsWith("OPEN")) {
                    fileVars.add(line.split(" ")[3]);
                    fileVarsCompleter.setFileVars(fileVars);
                }
            } catch (UserInterruptException e) {
                // Do nothing
            } catch (EndOfFileException e) {
                System.out.println("\nBye.");
                return;
            }
        }
    }
}
複製代碼

命令歷史

前面已通過說,在默認狀況下,JLine3 已經支持命令歷史查找。不過咱們想加上一個特殊的功能:用戶輸入的註釋(以 # 開頭)不會進入命令歷史,從而在命令歷史查找時不受註釋內容的干擾。

JLine3 中,History 負責控制歷史記錄的行爲,其默認實現爲 DefaultHistory。查看源代碼,咱們發現 add() 方法是其核心行爲。用戶輸入的一行命令,會經過 add() 方法加入命令歷史中。

@Override
public void add(Instant time, String line) {
    Objects.requireNonNull(time);
    Objects.requireNonNull(line);

    if (getBoolean(reader, LineReader.DISABLE_HISTORY, false)) {
        return;
    }

    // ...

    internalAdd(time, line);

    // ...
}
複製代碼

一樣地,咱們能夠經過繼承並重寫 add() 方法,將註釋內容過濾掉,不加入命令歷史:

public final class FogHistory extends DefaultHistory {

    private static boolean isComment(String line) {
        return line.startsWith("#");
    }

    @Override
    public void add(Instant time, String line) {
        if (isComment(line)) {
            return;
        }
        super.add(time, line);
    }
}
複製代碼

而後咱們這樣設置 LineReader

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(fogCompleter)
        .history(new FogHistory())
        .build();
複製代碼

總結

咱們發現,JLine3 的各個功能設計得比較清晰,有其對應的接口和默認實現。若是咱們想自定義一些特性,通常經過繼承並重寫的方式能夠作到。JLine3 的源代碼也比較容易理解,遇到困難時,能夠本身閱讀源代碼來尋找線索。

本文中示例程序的完整代碼參見 jline3-demo

相關文章
相關標籤/搜索