跟我一起寫shell補全指令碼(Bash篇)

spacewander發表於2019-02-16

上一篇裡我們定下了給pandoc寫補全指令碼的計劃:

  1. 支援主選項(General options)
  2. 支援子選項(Reader options/General writer options)
  3. 支援給選項提供引數值來源。比如在敲pandoc -f之後,能夠補全FORMAT的內容。

支援主選項

先列出實現了第一階段目標的程式:

bash# 以pandoc的名字儲存下面的程式
_pandoc() {
    local pre cur opts

    COMPREPLY=()
    #pre="$3"
    #cur="$2"
    pre=${COMP_WORDS[COMP_CWORD-1]}
    cur=${COMP_WORDS[COMP_CWORD]}
    opts="-f -r -t -w -o --output -v --version -h --help"
    case "$cur" in
    -* )
        COMPREPLY=( $( compgen -W "$opts" -- $cur ) )
    esac
}
complete -F _pandoc -A file pandoc

執行程式的方式:

shell$ . ./pandoc # 載入上面的程式
$ pandoc -[Tab][Tab] # 試一下補全能用不

現在我來解釋下這個程式。

bashcomplete -F _pandoc -A file pandoc

是這段程式碼中最為關鍵的一行。其實該程式起什麼名字都不重要,重要的是要有上面這一行。上面這一行指定bash在遇到pandoc這個詞時,呼叫_pandoc這個函式生成補全內容。(叫_pandoc其實只是出於慣例,並不一定要在前面加下劃線)。complete -F後面接一個函式,該函式將輸入三個引數:要補全的命令名、當前游標所在的詞、當前游標所在的詞的前一個詞,生成的補全結果需要儲存到COMPREPLY變數中,以待bash獲取。-A file表示預設的動作是補全檔名,也即是如果bash找不到補全的內容,就會預設以檔名進行補全。

假設你在鍵入pandoc -o sth後,連擊兩下Tab觸發了補全,_pandoc會被執行,其中:

  1. $1的值為pandoc
  2. $2的值為sth
  3. $3的值為-o
  4. 由於COMPREPLY為空(只有cur-開頭時,COMPREPLY才會被填充),所以補全的內容是當前路徑下的檔名。

你應該看到了,這裡我把$2$3都註釋掉了。其實

bashpre="$3"
cur="$2"

bashpre=${COMP_WORDS[COMP_CWORD-1]} # COMP_WORDS變數是一個陣列,儲存著當前輸入所有的詞
cur=${COMP_WORDS[COMP_CWORD]}

是等價的。不過後者的可讀性更好罷了。

最後解釋下COMPREPLY=( $( compgen -W "$opts" -- $cur ) )這一行。
opts就是pandoc的主選項列表。
compgen接受的引數和complete差不多。這裡它接受一個以IFS分割的字串"$opts"作為補全的候選項(IFS即shell裡面表示分割符的變數,預設是空格或者Tab、換行)。假如沒有一項跟當前游標所在的詞匹配,那麼它返回當前游標所在的詞作為結果。(也即是不補全)

實現第一個目標用到的東西就是這麼多。接下來就是第二個目標了。
在繼續之前,你需要把Bash文件看一遍。若能把其中的一些選項嘗試一下就更好了。

支援子選項

接下來的目標是支援Reader options/General writer options。想判斷是否需要補全Reader options/General writer options,先要確認輸入的詞裡面是否有-r-f(讀),以及-w-t(寫)。前面提到的COMP_WORDS就派上用場了。只需要將它迭代一下,查詢裡面有沒有我們需要確認的詞。

假設我們已經確認了需要補全子選項,接下來就應該往原來的補全項中新增子選項的內容。需要補全讀選項的新增讀方面的選項,需要補全寫選項的新增寫方面的選項。既然補全選項是一個字串,那麼把要新增的字串接到原來的opts後面就好了。這裡要注意一點,假如前面的操作裡面已經把某類子選項新增到opts了,那麼就需要避免重複新增。

目前的實現程式碼如下:

bash_pandoc() {
    local pre cur

    COMPREPLY=()
    #pre="$3"
    #cur="$2"
    pre=${COMP_WORDS[COMP_CWORD-1]}
    cur=${COMP_WORDS[COMP_CWORD]}
    complete_options() {
        local opts i
        opts="-f -r -t -w -o --output -v --version -h --help"
        for i in "${COMP_WORDS[@]}"
        do
            if [ "$i" == "-f" -o "$i" == "-r" ]
            then
                opts="$opts"" -R -S --filter -p"
                break
            fi
        done

        for i in "${COMP_WORDS[@]}"
        do
            if [ "$i" == "-t" -o "$i" == "-w" ]
            then
                opts="$opts"" -s --template --toc"
                break
            fi
        done
        echo "$opts"
    }

    case "$cur" in
    -* )
        COMPREPLY=( $( compgen -W "$(complete_options)" -- $cur ) )
    esac
}
complete -F _pandoc -A file pandoc

注意跟上一個版本相比,這裡把原來的opts變數替換成了complete_options這個函式的輸出。通過使用函式,我們可以動態地提供補全的來源。比如我們可以在函式裡列出符合特定條件的檔名,作為補全的候選詞。

支援給選項提供引數值來源

好了,現在是最後一個子任務。大致瀏覽一下pandoc的文件,基本上就兩類引數:FORMATFILE。(其它瑣碎的我們就不管了,嘿嘿)

FILE好辦,預設就可以補全路徑嘛。那就看看FORMATFORMAT分兩種,一種是讀的時候支援的FORMAT,另一種是寫的時候支援的FORMAT,這個把文件裡面的複製一份,改改就能用了。我們把讀操作支援的FORMAT叫做READ_FORMAT,相對的,寫操作支援的FORMAT叫做WRITE_FORMAT

補全的來源有了,想想什麼時候把它放到COMPREPLY裡去。前面補全選項的時候,是通過case語句中-*來匹配的。但是這裡的FORMAT引數,只在特定選項後面才有意義。所以前面一直坐冷板凳的pre變數可以上場了。

pre中儲存著游標前一個詞。我們就用一個case語句判斷前面是否是-f-r,還是-t-w。如果符合前面兩個組合之一,用compgen配合READ_FORMATWRITE_FORMAT生成補全候選詞列表,一切就跟處理opts時一樣。由於此時繼續參與下一個判斷cur的case語句已經沒有意義了,這裡直接讓它退出函式:

bashREAD_FORMAT="native json markdown markdown_strict markdown_phpextra 
    markdown_github textile rst html docbook opml mediawiki haddock latex"
WRITE_FORMAT="native json plain markdown markdown_strict 
    markdown_phpextra markdown_github rst html html5 latex beamer context 
    man mediawiki textileorg textinfo opml docbook opendocument odt docx 
    rtf epub epub3 fb2 asciidoc slidy slideous dzslides revealjs s5"

case "$pre" in
-f|-r )
    COMPREPLY=( $( compgen -W "$READ_FORMAT" -- $cur ) )
    return 0
    ;;
-t|-w )
COMPREPLY=( $( compgen -W "$WRITE_FORMAT" -- $cur ) )
    return 0
esac

. ./pandoc一下,試試看,是不是一切都ok?

誒呀,還有個問題!這次在嘗試補全FORMAT的時候,還會把當前路徑下的檔名補全出來。然而這並沒有什麼意義。所以在補全FORMAT的時候,得把路徑補全關掉才行。

問題在於最後一句:complete -F _pandoc -A file pandoc。目前不管是什麼情況,都會補全檔名。所以接下來得限定某些情況下才補全檔名。

第一步是移除最後一行的-A file,下一步是修改最底下的case語句,變成這樣子:

bashcase "$cur" in
-* )
    COMPREPLY=( $( compgen -W "$(complete_options)" -- $cur ) );;
* )
    COMPREPLY=( $( compgen -A file ))
esac

只有在沒有找到對應的補全時,才會呼叫對路徑的補全。

最終版本:

bash_pandoc() {
    local pre cur

    COMPREPLY=()
    #pre="$3"
    #cur="$2"
    pre=${COMP_WORDS[COMP_CWORD-1]}
    cur=${COMP_WORDS[COMP_CWORD]}
    READ_FORMAT="native json markdown markdown_strict markdown_phpextra 
    markdown_github textile rst html docbook opml mediawiki haddock latex"
    WRITE_FORMAT="native json plain markdown markdown_strict 
    markdown_phpextra markdown_github rst html html5 latex beamer context 
    man mediawiki textileorg textinfo opml docbook opendocument odt docx 
    rtf epub epub3 fb2 asciidoc slidy slideous dzslides revealjs s5"

    case "$pre" in
    -f|-r )
        COMPREPLY=( $( compgen -W "$READ_FORMAT" -- $cur ) )
        return 0
        ;;
    -t|-w )
        COMPREPLY=( $( compgen -W "$WRITE_FORMAT" -- $cur ) )
        return 0
    esac

    complete_options() {
        local opts i
        opts="-f -r -t -w -o --output -v --version -h --help"
        for i in "${COMP_WORDS[@]}"
        do
            if [ "$i" == "-f" -o "$i" == "-r" ]
            then
                opts="$opts"" -R -S --filter -p"
                break
            fi
        done

        for i in "${COMP_WORDS[@]}"
        do
            if [ "$i" == "-t" -o "$i" == "-w" ]
            then
                opts="$opts"" -s --template --toc"
                break
            fi
        done
        echo "$opts"
    }

    case "$cur" in
    -* )
        COMPREPLY=( $( compgen -W "$(complete_options)" -- $cur) )
        ;;
    * )
        COMPREPLY=( $( compgen -A file ))
    esac
}
complete -F _pandoc pandoc

最後的問題

現在補全指令碼已經寫好了,不過把它放哪裡呢?我們需要找到這樣的地方,每次啟動bash的時候都會自動載入裡面的指令碼,不然每次都要手動載入,那可吃不消。

.bashrc是一個(不推薦的)選擇,不過好在bash自己就提供了在啟動時載入補全指令碼的機制。

如果你的系統有這樣的資料夾:/etc/bash_completion.d,那麼你可以把補全指令碼放到那。這樣每次bash啟動的時候就會載入你寫的檔案。

如果你的系統裡沒有這個資料夾,你需要檢視下/etc/bash_completion這個檔案。bash啟動的時候,會執行. /etc/bash_completion,你可以把你的補全指令碼放在這個地方。

正如許多配置檔案一樣,凡是有/etc版本的也對應的~/.版本。有/etc/bash_completion,自然也有~/.bash_completion。如果你只想讓自己使用這個補全指令碼,或者沒有root許可權,可以放在~/.bash_completion

Bash補全指令碼的內容就是這麼多……請期待下一篇的Zsh補全指令碼。

相關文章