テキスト・エディタの文字列補完

Emacs は文字列補完機能をもっています。 補完機能を使うには、 文字をいくつかタイプして TAB キーを押します。 すると、 タイプ済みの文字列で始まるコマンド名やファイル名を探して長い文字列に展開してくれます。 Emacs のコマンド名には長いものが多いのですが、 補完機能のおかげで、 少ないタイプ量で入力できるようになっています。 逆に言えば、 長くて意味を読み取りやすいコマンド名をためらうことなくつけることができるわけです。 短いタイプ量で済むように暗号のように文字数を切り詰めたコマンド名を使わなくて済みます。

補完機能はミニバッファで使うことが多く、 TAB キーが minibuffer-complete コマンドに結合してあります。 これによってミニバッファにタイプ済みの文字列を元に、 入力候補のリストから文字列を補えるだけ補ってくれます。

class MiniBufferComplete < Interactive
  def self.name() :minibuffer_complete end

  def edit(arg)
    minibuffer.complete()
  end
end

補完機能によって、 文字列を補うだけでなく、 候補が複数あるときに絞り込まれた候補を一覧表示させることもできます。 ところで、 GNU Emacs の候補一覧表示は新しく一時バッファとウィンドウを作成して表示します。 大抵の場合、 入力したい文字列は候補リストの先頭側に並ぶ傾向があるため、 目線を画面最下のミニバッファから上へ半画面分移動してまた下に戻すことになります。 一方、 bash では入力行のすぐ下に ls (1) コマンドのカラム形式で一覧表示をしてくれます。 こちらは、 経験上、 ほんの少し目線を移動するだけで済みます。

これまで使用してきて、 bash の方が好ましいと感じてきたので、 そちらを実装することにします。 MiniWindow に高さ自動判別複数行表示機能をつけてあるので、 bash 流儀の候補一覧表示を簡便に実装できます。 文字列補完を TAB キーでおこない、 複数候補があるとき TAB キーをもう一度タイプすると、 入力行の下に候補のリストを表示させます。 ただし、 あまり大量に表示しても見にくいだけなので、 7 行分に限定しています。 そこで入力を続けると候補リストは即座に隠れます。

補完用の文字列のリストはあらかじめ与えておいも良いですし、 ファイル・システムのディレクトリから読み取るように指定することもできます。 例えば、 execute-extended-command (ESC x) コマンドは、 現在のバッファのモードからコマンド名リストを作成してミニバッファでの文字列入力を開始します。 エディタの read_string メソッドはプロンプトとデフォルト文字列に加えて、 補完用リストを指定してミニバッファによる文字列編集を開始します。 ミニバッファの編集が終わると MiniWindow のバッファの内容から文字列を作って、 read_string メソッドに指定したブロックを実行します。 ブロック中で、 入力されたコマンド名のコマンドを実行します。 そのとき、 execute-extended-command の数引数を、 コマンドの数引数として渡します。

class ExecuteExtendedCommand < InteractiveBase
  def self.name() :execute_extended_command end

  def edit(arg)
    prefix = editor.command.prefix_to_s     # 数引数のタイプ内容
    prefix.empty? or prefix << ' '
    list = current_buffer.mode.interactive.keys.sort.map {|k| k.to_s.tr('_', '-') }
    screen.read_string(prefix + 'Command: ', '', completion: list) {|str|
      screen.minibuffer_pop()               # ミニバッファから抜けだす
      name = str.tr('-', '_').intern
      if current_buffer.mode.interactive.key?(name)
        editor.edit(name, arg)              # 指定されたコマンドを実行
      else
        screen.miniwindow.print('', '(Unknown %s)' % [str])
      end
    }
  end
end

ファイル名をミニバッファで指定したいときは read_file_name メソッドを使います。 これは、 read_string の補完リストをディレクトリから取得するように変更したものです。 なお、 ミニバッファの control 属性は利用者と一連の対話を進めるための状態変数であり、 補完とは関係ありません。

module FileInteractivable
  def read_file_name(ctrl, prompt, &blk)
    dirname = default_dirname(window.buffer.filename)
    screen.read_string(prompt, dirname, completion: :file_name_completion, &blk)
    minibuffer.control = ctrl
  end
end

文字列補完機能は Completable モジュールに閉じ込めています。 Completable を include するクラスには、 5 つのアダプタを定義しておかなければなりません。 候補リストを返す completion メソッドの戻り値は、 Enumerable か Proc か :file_name_completion のバリアントでなければなりません。 ミニバッファでは completion 属性です。 補完対象になる文字列を返すのが completion_pattern_get メソッドで、 ミニバッファでは buffer.to_s でバッファの内容そのものにします。 補完した結果の文字列をバッファに置換するのが completion_result_replace メソッドです。 選別済みの候補リストを表組みして文字列にしてものを再表示のために記録しておくのが completion_table_set! です。 さらに、 表組みに必要な表示幅を返す window_width メソッドも提供しなければなりません。

module Completable
  # host class's adapter methods
  #
  # completion() -- get the completion list
  # completion_pattern_get() -- the pattern
  # completion_result_replace(str) -- completed result
  # completion_table_set!() -- print string of a completion table
  # window_width() -- get the window width in the unit of columns

#@<complete@>

private

#@<file_name_completion@>
#@<try_completion@>
#@<layout_completion_table@>
end

complete メソッドは、 ディレクトリからリストを作らなければならないときは file_name_completion を頼ります。 そうでないときは、 completion 属性をリストに使うのですが、 もしも completion 属性が Proc オブジェクトのときは call して動的にリストを作成することもできるようになっています。

#@<complete@>=
  def complete()
    if :file_name_completion == completion
      file_name_completion()
    else
      list = completion
      if list.respond_to?(:call)
        list = list.call(self)
      end
      try_completion(completion_pattern_get(), list) {|x| x }
    end
  end

file_name_completion は、 現在のバッファの内容からディレクトリのパスを抜き出して、 ディレクトリのエントリを取得します。

#@<file_name_completion@>=
  def file_name_completion()
    pathname = completion_pattern_get()
    dirname, pattern = pathname, ''
    if ! File.directory?(pathname)
      dirname, pattern = File.split(pathname)
    end
    File.directory?(dirname) or return
    list = Dir['*', base: dirname].map {|s| File.directory?(s) ? s + '/' : s }
    list = list.select {|s| %r/[~\#]\z/ !~ s }
    try_completion(pattern, list) {|basename| File.join(dirname, basename) }
  end

いずれにせよ、 実際の文字列補完は try_completion がおこないます。 最初にやるべきことは、 文字列リストから、 先頭がバッファ文字列と一致しているものを候補として抜き出すことです。 候補が 1 つしかないときは、 補完は終わりです。 見つけた候補にバッファの内容を買い換えます。 候補が 2 つ以上あるとき、 候補の共通前置区の長さを i に求めます。 バッファ文字列と共通前置区が一致しないときは、 バッファを共通前置区で置き換えます。 一致するときは、 既に候補探索は終わっている場合で、 補完できないのに TAB を 2 回以上叩いたときです。 そのときは候補リストを表組みします。

#@<try_completion@>=
  def try_completion(pattern, list)
    completion_table_set!(nil)
    list.respond_to?(:each) or return
    a = list.select {|s| s[0, pattern.size] == pattern }
    if a.size == 1
      str = yield a.first
      dot.point = 0
      completion_result_replace(str)
    elsif a.size > 1
      i = 0
      while i < a[0].size && a.all? {|s| s[i] == a[0][i] }
        i += 1
      end
      if pattern != a[0][0 ... i]
        str = yield a[0][0 ... i]
        dot.point = 0
        completion_result_replace(str)
      else
        table = ColumnLayout.new(window_width()).render(a)
        completion_table_set!(table.string)
      end
    end    
  end

ColumnLayout は、 ls (1) コマンドのカラム形式出力の initialize と column_width_of_string を修正して流用します。

class ColumnLayout
  def initialize(x, tty)
    @size = Point2d.new(0, x)
    @tty = tty
    @tabskip = []
    @string = ''
  end
end

#@<column_width_of_string>=
  def column_width_of_string(s)
    s.grapheme_clusters.inject(0) {|r, c| r + @tty.wcwidth(c.ord) }
  end

ミニバッファから文字列補完に関係する部分を抜き出します。

class MiniBuffer < MiniBufferBase
  include Completable

  attr_accessor :completion, :completion_table

  def initialize(window)
    super
    @completion = nil
    @completion_table = nil
    #省略
  end

#@<completion_pattern_get@>
#@<completion_result_replace@>
#@<completion_table_set!@>
#@<window_width@>
end

ミニバッファの場合、 補完される対象文字列は MiniWindow のバッファの内容全体にしています。

#@<completion_pattern_get@>=
  def completion_pattern_get()
    miniwindow.buffer.to_s
  end

補完された文字列を置換するときは、 MiniWindow のバッファの内容全部を削除し、 補完済み文字列を挿入します。 補完後、 文字列の末尾へ移動します。 ところで、 ミニバッファに文字列を打ち込んで、 前の文字へ現点を戻したときは、 どうふるまえば良いのでしょうか。 ここでは、 現点がどこにあろうとも、 バッファ全体をパターンとみなして補完するようにしてます。 もう一つの流儀として感が得られるのは、 現点に正規表現.* があるものとして補完・展開するというやりかたもあるのかもしれません。

#@<completion_result_replace@>=
  def completion_result_replace(str)
    miniwindow.dot.point = 0
    miniwindow.buffer.delete(miniwindow.dot, miniwindow.buffer.size)
    miniwindow.buffer.insert(miniwindow.dot, str)
  end

表示幅に MiniWindow の幅を使います。

#@<window_width@>=
  def window_width()
    miniwindow.size.x
  end

候補リストを表組みした文字列を、 いったん completion_table 属性に代入しておきます。

#@<completion_table_set!@>=
  def completion_table_set!(str)
    @completion_table = str
  end

#@<redisplay@>
#@<before_execute_command@>

次回の再表示時点で、 MiniWindow のバッファ末尾に一時的に表示させます。 redisplay_setup メソッドは再表示の最初に呼ばれます。 その中でバッファの末尾へ候補リストをくっつけておきます。 redisplay_teardown メソッドは再表示の最後に呼ばれます。 そこで、 バッファの末尾の候補リストを削除しておきます。

#@<redisplay@>=
  def redisplay_setup()
    if @completion_table
      dol = Mark.new(buffer.size, 0)
      buffer.insert(dol, "\n")
      buffer.insert(dol, @completion_table)
    end
  end

  def redisplay_teardown()
    if @completion_table
      dol = Mark.new(buffer.size, 0)
      buffer.delete(dol, -1 - @completion_table.size)
    end
  end

ミニバッファに文字列を入力したり、 他のキーに結合しているキーを押すと、 候補リストの表示は消えてなくなります。

#@<before_execute_command@>=
  def before_execute_command(command)
    @completion_table = nil
  end