テキスト・エディタの行の折り返し表示 その 4 - キャッシュ更新の改善

行の折り返し表示 その 3」 でテキスト・エディタで長い文書ファイルを開いても、 表示速度が極端に落ちないようにと、 行の折り返し表示のための書記素クラスタ情報をキャッシュすることにしました。 ところが、 キャッシュは良いことばかりではないようで、 キー入力による文字挿入・削除の応答性を改善するために速度を落としている箇所を探してみたところ、 キャッシュ追加時の each ループが原因になっていました。

#@<unshift@>=
  def unshift(value)
    # 問題部分
    each do |e|                                                     # !
      if e.value.last >= value.first && value.last > e.value.first  # !
        delete(e)                                                   # !
      end                                                           # !
    end                                                             # !
    super(value)
  end

このループは、 バッファが改行で終わるとき、 バッファ末尾の書記素クラスタ情報で line.first == line.last になっておりキャッシュに残っていた分を削除するのに必要でした。 他の場合は、 行内の場所 loc に対して、 line.first <= loc < line.last がなりたつので文字挿入・削除時にキャッシュから削除できていました。

改善前

  バッファ          書記素クラスタ情報
  "quick brown\n"   Lines.new(0, 12, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
  "fox jumps\n"     Lines.new(12, 22, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  ""                Lines.new(22, 22, [0])

バッファ末尾を含めて、 すべての書記素クラスタ情報で、 line.first <= loc < line.last がなりたてば、 挿入・削除時にキャッシュから削除できる理屈で、 キャッシュ追加時の余計な重い each ループが不要になります。 そのためには、 擬似的にバッファ末尾を表す制御文字があるかのように書記素クラスタ情報を作れば良いわけです。 つまり、 バッファの正味の文字数は 22 文字なのですが、 擬似末尾制御文字を含めて書記素クラスタを 23 個とします。

改善後

  バッファ          書記素クラスタ情報
  "quick brown\n"   Lines.new(0, 12, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
  "fox jumps\n"     Lines.new(12, 22, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  ""                Lines.new(22, 23, [0, 1])

改善後では、 バッファ末尾が改行で終わってないときも同様に考えて、 擬似的なバッファ末尾文字があるかのように書記素クラスタ情報を作ることにします。 この場合、 バッファの正味の文字数は 21 文字なのですが、 擬似末尾制御文字を含めて書記素クラスタを 22 個とします。

改善後

  バッファ          書記素クラスタ情報
  "quick brown\n"   Lines.new(0, 12, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
  "fox jumps"       Lines.new(12, 22, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

ギャップ・バッファの作る書記素クラスタ情報 Line は行の折り返しを考慮しません。 求めたいユーザ座標 loc を含む改行文字と改行文字の間を行として、 その中に含まれている書記素クラスタの開始ユーザ座標の相対値をリストアップします。 前のコードではギャップを挟むとき、 文字列に切り出してから書記素クラスタの開始位置を求めていたのですが、 今回はそこも改善し、 文字列切り出しなしで書記素クラスタをたどっていきます。 その場合、 書記素クラスタの途中にギャップが開いている可能性があるので、 ギャップの直前の書記素クラスタを、 ギャップ直後へ移動してから書記素クラスタをもう一度、 同じ文字からたどらせています。 ただし、 この改善による応答性への寄与は小さく、 上の each ループの除去ほどの効果はありません。

Line = Struct.new(:first, :last, :h)

class GapEditor
  GRAPHEME_CLUSTER = %r/\G\X/

  def grapheme_clusters_of_line(loc)
    left = find_first_in_backward("\n", loc, false)
    dol = find_first_in_forward("\n", loc, false)
    line = Line.new(left, dol + 1, [])
    right = (dol < size) ? dol + 1 : dol
    data = @data
    i = left
    if left < @gap_start && @gap_start < right
      gs = @gap_start
      while i < gs
        line.h << i - left
        i = GRAPHEME_CLUSTER.match(data, i).end(0)
      end
      gap_move(left + line.h.last)
      line.h.pop
      i = @gap_start
    end
    gap = right <= @gap_start ? 0 : @gap_end - @gap_start
    i, left_gap, right_gap = i + gap, left + gap, right + gap
    while i < right_gap
      line.h << i - left_gap
      i = GRAPHEME_CLUSTER.match(data, i).end(0)
    end
    while true
      line.h << right - left
      (line.h.last + line.first < line.last) or break
      right += 1
    end
    line
  end

  # 省略
end

末尾の特別扱いが不要になったので、 キャッシュへの追加時の削除が不要になり、 unshift は Cache クラスのものをそのまま使えるようになります。 キャッシュからの検索に使う条件式も素直になります。

class RangeCache < Cache
  def lookup(loc)
    each do |e|
      r = e.value
      if r.first <= loc && loc < r.last
        touch(e)
        return r
      end
    end
    nil
  end
end

Buffer クラスで、 書記素クラスタ情報 Line をキャッシュするやりかたは、 前のままで変化はありません。 キャッシュにあるときは、 それを使い、 ないときはギャップ・バッファに Line オブジェクトを作ってもらってキャッシュに追加します。

class Buffer
  def grapheme_clusters_of_line(loc)
    line = @line_cache.lookup(loc)
    if line.nil?
      line = content.grapheme_clusters_of_line(loc)
      @line_cache.unshift line
    end
    line
  end

#@<content_insert, content_delete@>

  # 省略
end

文字挿入・削除で、 以前はバッファの内容を更新してからキャッシュとマークを更新していたのですけど、 更新の順番が逆の方が、 デバッグ時の挙動の追跡がわかりやすいことに気がついたので、 逆にしています。

#@<content_insert, content_delete@>=
  def content_insert(dot, str)
    count = str.size
    screen.window_of_buffer_owner(self).each do |win|
      update_mark_insert(win.mark_list, dot, count)
      update_cache(win.layout.cache, dot, 0, count)
    end
    update_mark_insert(@mark_list, dot, count)
    update_cache(@line_cache, dot, 0, count)
    content.insert(dot.point, str)
    dot.point += count
    @revision += 1
    self
  end

  def content_delete(dot, count)
    count_abs = count.abs
    screen.window_of_buffer_owner(self).each do |win|
      update_mark_delete(win.mark_list, dot, count_abs)
      update_cache(win.layout.cache, dot, count_abs, -count_abs)
    end
    update_mark_delete(@mark_list, dot, count_abs)
    update_cache(@line_cache, dot, count_abs, -count_abs)
    content.delete(dot.point, count)
    @revision += 1
    self
  end

#@<update_cache@>

キャッシュの更新は前のままです。 そのままでもバッファ末尾のキャッシュを扱えるようになりました。 次のコードは dot.point をくくりだしただけで、 本質は前と同じです。

#@<update_cache@>=
  def update_cache(cache, dot, count1, count2)
    loc = dot.point
    cache.each do |e|
      if e.value.first <= loc + count1 && loc < e.value.last
        cache.delete(e)
      elsif e.value.first > loc
        e.value.first += count2
        e.value.last += count2
      end
    end
  end

Layout クラスが、 書記素クラスタ情報 Line から行を折り返して、 Grid を作ります。 Line の変更によって、 影響を受けるのは、 現在の Grid がバッファ末尾を含むことをチェックする箇所です。 今度は、 grid.last がバッファ・サイズを越えていることを調べるようにします。

class Layout
  def eos?()
    @grid.last > window.buffer.size
  end

#@<layout@>

  # 省略
end

書記素クラスタ開始位置配列から行の折り返し位置を求める layout クラスは前と同じで良いのですが、 バッファ末尾でも Line の開始位置配列が必ず 2 個以上になったことから、 末尾が 1 個のときを特別扱いしなくて済むようになります。

#@<layout@>=
  def layout(loc)
    tty = window.screen.tty
    buffer = window.buffer
    @grid = @cache.lookup(loc)
    if @grid.nil?
      tab_width = buffer.mode.tab_width
      width = window.size.x
      line = buffer.grapheme_clusters_of_line(loc)
      @grid = Grid.new(line.first, line.last, [[]])
      x = 0
      line.h.each_index do |j|
        loc = line.h[j] + line.first
        len = line.h[j + 1] - line.h[j]
        ch = loc < buffer.size ? buffer[loc] : END_BUFFER
        span = END_BUFFER == ch ? 0 \
             : "\n" == ch ? 0 \
             : "\t" == ch ? tab_width - (x % width) % tab_width
             : ch.ord < 0x20 ? 2
             : tty.wcwidth(ch.ord)
        if x % width + span > width
          x += width - x % width
          @grid.v << []
        end
        @grid.v[-1] << HBox.new(line.h[j], len, span)
        if span > 1
          (span - 1).times {|d| @grid.v[-1] << HBox.new(line.h[j], 0, -(d + 1)) }
        end
        break if END_BUFFER == ch || "\n" == ch
        if x % width + span >= width
          @grid.v << []
        end
        x += span
      end
      @cache.unshift @grid
    end
    self
  end

以上で、 キャッシュの更新を挿入・削除時に完了させることができるようになり、 キャッシュ追加時のループを削除することができました。 しないでも良い無駄な処理をしないで済ますようにデータを構成する大切さを体感できる事例でした。

テキスト・エディタのマルチ・バッファ

ここまでで、 マルチ・バッファにする下地は整っているので、 まずは、 落穂拾いから始めます。

テキスト挿入もしくは削除に伴い、 マークと行折り返しレイアウト・キャッシュを更新する目的で、 Window に buffer 属性を、 Buffer に window 属性を設けて相互リンクしていました。 Window に buffer 属性があるのは当然としても、 Buffer に window 属性が必要な理由は、 Buffer が更新するマークとキャッシュに Window に所属するものがあるためです。

ところが、 この方式では Window と Buffer の関連付けが変わるごとに、 その都度、 相互リンクのつじつま合わせをおこなわねばなりません。 例えば、 switch-to-buffer (C-x b) コマンドで Window が表示しているバッファを変更すると、 それまで表示していた Buffer の window 属性から Window 自身を取り除かなければなりません。 続いてこれから表示する Buffer の window 属性に window 自身を追加します。 これらは暗黙のうちにおこなわれるようにコーディングしてあるものの、 delete-window (C-x 0) コマンド等では、 メソッドを明示的に実行することで、 Window と Buffer の関連付けを切っていました。

予防処置として、 双方向リンクのつじつま合わせを明示的にコードに書き込むのは避けておきたいものです。 なぜならば、 メモリ・リークの原因の一つが双方向リンクの切断ミスだからです。 多少のオーバー・ヘッドで済むなら、 Window と Buffer の双方向リンクなしでマークとキャッシュの更新ができるように、 修正しておきましょう。

都合の良いことに、 今や、 すべての Window を Screen が保持しているので、 Buffer から Screen へリンクすることで、 循環リンクを作ることができます。 これを使うことで、 Buffer が更新したいマークとキャッシュを所有する Window を探すことが可能です。 また、 screen から window を削除するだけで、 自動的に screen → window → buffer → screen の循環リンクを切ることができ、 ゴミ集めが回収対象の window オブジェクトを検出できるようになります。

まず、 Screen クラスの initialize をブロックで初期化をおこなえるように変更し、 今度は、 最初の画面表示用の buffer と、 miniwindow 用の buffer をブロックで作るようにしました。 これにより、 buffer を作るには screen が必要で、 screen を初期化するのには buffer が欲しいという事情に対応しています。

class Screen < ScreenBase
  def initialize(tty)
    super(tty)
    @window_min_height = 4
    @tile = []
    buffer, miniwin_buffer = yield self
    @activated_window = @tile[0] = Window.new(self, buffer)
    @miniwindow = MiniWindow.new(self, miniwin_buffer)
  end

  def window_of_buffer_owner(buffer)
    each_window.select {|win| win.buffer.equal?(buffer) }
  end

  def each_window
    block_given? or return to_enum(:each_window)
    @tile.each {|win| yield win }
    @miniwindow.nil? or yield @miniwindow
    self
  end

#@<delete_window@>
end

Screen の delete_window メソッドで、 もはや相互リンクを切るメッセージを window オブジェクトへ送る必要がなくなりました。

#@<delete_window@>=
  def delete_window(window=nil)
    not one_window?() or return
    i = find_index_window(window) or return
    window = tile[i]                          #+
    # window = tile[i].buffer_release         #-
    if selected_window.equal?(window)
      select_window(next_window)
    end
    if i == tile.size - 1
      tile[i - 1].size.y += window.size.y
    else
      tile[i + 1].topleft.y = window.topleft.y
      tile[i + 1].size.y += window.size.y
    end
    tile.delete_at(i)
    self
  end

今度は Buffer から window 属性を削除して screen 属性を追加します。 window_raise メソッドが不要になったので撤去します。

class Buffer
  attr_reader :screen
  # attr_reader :window       #- 削除

  def initialize(screen, name)
    @name = name.dup
    @screen = screen
    # 省略
  end

#@<content_insert@>
#@<content_delete@>

  # def window_raise(win) ... end         #-
end

content_insert で window 属性の each を使っていた箇所を、 screen 属性を使って書き改めます。 修正箇所は 1 行だけです。 content_delete も同様に修正すれば良いので、 省略します。

#@<content_insert@>=
  def content_insert(dot, str)
    content.insert(dot.point, str)
    count = str.size
    screen.window_of_buffer_owner(self).each do |win|     #!
      update_mark_insert(win.mark_list, dot, count)
      update_cache(win.layout.cache, dot, 0, count)
    end
    update_mark_insert(@mark_list, dot, count)
    update_cache(@line_cache, dot, 0, count)
    dot.point += count
    @revision += 1
    self
  end

#@<content_delete@>=
  # 同上につき省略

WindowBase から Buffer との相互リンクを作る箇所を削除します。

class WindowBase
  attr_reader :screen, :buffer, :topleft, :size, :cursor
  attr_reader :dot, :start, :mark_list
  attr_reader :layout

  def initialize(screen, buffer)
    @screen = screen
    @buffer = buffer
    # @buffer.window << self                  #-
    # 省略
  end

  # 省略
end

Window から buffer_release を削除します。 現点と表示開始点をセーブして、 レイアウト・キャッシュを空にする部分は buffer= へ引き継ぎました。 バッファが表示されなくなってから、 再度表示されるとき、 以前の現点と表示開始点を覚えておくために、 Buffer にも dot 属性と start 属性があります。 なお、 他の window に表示中のバッファへに変えるときは、 一番上の window から現点と表示開始点を受け継ぎます。

class Window
  def buffer=(buf)
    a = (buf.nil?) ? [] : screen.window_of_buffer_owner(buf)
    if @buffer
      @buffer.dot.point = dot.point
      @buffer.start.point = start.point
      @buffer.start.revision = start.revision
    end
    layout.cache.clear
    @buffer = buf or return buf
    if a.empty?
      dot.point = @buffer.dot.point
      dot.revision = 0
      start.point = @buffer.start.point
      start.revision = @buffer.start.revision
    else
      dot.point = a.first.dot.point
      dot.revision = 0
      start.point = a.first.start.point
      start.revision = a.first.start.revision
    end
    buf
  end
end

予防処置はここまでです。 ここから、 マルチ・バッファのための機能追加に入ります。

表示中の window を screen が管理しているように、 開いている buffer を editor が管理しています。 Editor に buffer_list 属性があり、 表示・未表示を問わず、 buffer はここに入れます。 例外は screen.miniwindow の buffer です。 混乱を避けるため、 これだけは buffer_list に入れてません。

class OptionError < RuntimeError
end

class Editor
  attr_reader :screen, :buffer_list
  # 省略

  def initialize()
    @screen = nil
    @buffer_list = []
    # 省略
  end

#@<getopt@>
#@<run@>
#@<get_buffer@>
#@<get_buffer_create@>
#@<get_buffer_create_from_path@>
#@<kill_buffer@>
end

get_buffer は buffer_list から、 名前が name である buffer を探します。 同時に見つけた buffer を先頭に移動し、 バッファ操作コマンドが最近使った順で buffer を探すのに備えます。

#@<get_buffer@>=
  def get_buffer(name)
    i = buffer_list.find_index {|x| x.name == name } or return nil
    buffer = buffer_list[i]
    buffer_list.delete_at(i)
    buffer_list.unshift buffer
    buffer
  end

上の探索で見つからなかったとき、 名前が name の buffer を新しく作って buffer_list に追加するのが、 get_buffer_create です。 新しく作った buffer の filename 属性は空文字列にしてあります。

#@<get_buffer_create@>=
  def get_buffer_create(name)
    buffer = get_buffer(name)
    if buffer.nil?
      buffer = Buffer.new(screen, name)
      buffer.mode = mode
      buffer.isearch.mode = search_local
      buffer_list.unshift buffer
    end
    buffer
  end

switch-to-buffer (C-x b) コマンドはこれを使って記述します。 buffer_list にあるバッファ名を補完に使う前準備があるものの、 ミニバッファでの対話結果で得た名前のバッファを得て、 ウィンドウのバッファを切り替えます。

class SwitchToBuffer < Interactive
  def self.name() :switch_to_buffer end

  def edit(arg=nil)
    name_default, list = take_buffer_completion()
    prompt = (name_default == '') ? 'Switch to buffer : ' \
           : 'Switch to buffer (default %s): ' % [name_default]
    screen.read_string(prompt, '', completion: list) do |str|
      screen.minibuffer_pop()
      if str == ''
        str = name_default
      end
      if str != '' && str[0] != ' '
        buffer = editor.get_buffer_create(str)
        if ! window.buffer.equal?(buffer)
          window.buffer = buffer
        end
      end
    end
  end

#@<take_buffer_completion@>
end

補完用リストは、 Editor の buffer_list から buffer.name を抜き出してリストにしたものです。 その際、 空文字列の名前と空白で始まる名前を除外します。 さらに、 その buffer が未表示のとき、 最近使ったものを優先してバッファのデフォルト名として選択します。

#@<take_buffer_completion@>=
  def take_buffer_list()
    name_default = nil
    list = []
    editor.buffer_list.each do |buf|
      if buf.name && buf.name[0] != ' '
        list << buf.name
        if screen.window_of_buffer_owner(buf).empty?
          name_default ||= buf.name
        end
      end
    end
    [name_default || '', list.sort]
  end

get_buffer_create と同じですが、 名前をパス名から作るのが get_buffer_create_from_path です。 このメソッドは、 単に名前が付いている空の buffer を作るだけで、 ファイルの読み込みをおこないません。

#@<get_buffer_create_from_path@>=
  def get_buffer_create_from_path(path)
    filename = File.expand_path(path)
    name = Buffer.filename_friendly(filename)
    get_buffer_create(name)
  end

find-file (C-x C-f) コマンドはこれを使って記述します。 ミニバッファでファイル名を受け取ってから、 バッファを求め、 ファイルを読み込みます。 既に開いているときは、 GNU Emacs とは異なり、 既にあるバッファに切り替えます。

class FindFile < Interactive
  include FileInteractivable

  def self.name() :find_file end

  def edit(arg=nil)
    return read_file(arg) if String === arg
    read_file_name(1, 'Find file: ') do |str|
      screen.minibuffer_pop()
      buffer = editor.get_buffer_create_from_path(str)
      window.buffer = buffer
      if buffer.filename.empty?
        read_file(str)
      end
    end
  end
end

Buffer の特異メソッド filename_friendly は、 絶対パスからディレクトリを適度に取り除いて、 名前を作る関数です。

require 'pathname'
require 'fileutils'

class Buffer
  def self.filename_friendly(filename)
    abspath = Pathname.new(filename)
    curdir = Pathname.pwd
    homedir = Pathname.new(Dir.home)
    if abspath.ascend.any? {|x| x == curdir }
      abspath.relative_path_from(curdir).to_s
    elsif abspath.descend.any? {|x| x == homedir } \
        && curdir.descend.any? {|x| x == homedir }
      abspath.relative_path_from(curdir).to_s
    else
      filename
    end
  end
end

kill_buffer は buffer_list から名前が name である buffer を取り除きます。 取り除いた後、 buffer_list が空になってしまったときは、 UNTITLED の名称で get_buffer_create します。 さらに、 削除対象 buffer を表示中の window を削除していきます。 window が 1 つだけ残り、 それも buffer を表示しているときは、 buffer_list の先頭か UNTITLED の表示に切り替えます。 最後に、 バッファの内容を掃除して、 バッファの削除は終わりです。

#@<kill_buffer@>=
  def kill_buffer(name)
    i = buffer_list.find_index {|x| x.name == name } or return
    buffer = buffer_list[i]
    buffer_list.delete_at(i)
    if buffer_list.empty?
      get_buffer_create('UNTITLED')
    end
    screen.window_of_buffer_owner(buffer).each do |window|
      not screen.one_window? or break
      screen.delete_window(window)
    end
    if screen.one_window? && screen.activated_window.buffer.equal?(buffer)
      if buffer_list.empty?
        screen.activated_window.buffer = get_buffer_create('UNTITLED')
      else
        screen.activated_window.buffer = buffer_list.first
      end
    end
    buffer.kill!
    nil
  end

kill-buffer (C-x 0) コマンドはこれを使って、 バッファを削除します。 現窓のバッファ名をデフォルトにして、 buffer_list から補完してミニバッファからバッファ名を得るところは一緒です。 もしバッファが変更されていないときは、 上の kill_buffer を使って削除して終わりです。 変更されているときは、 引き続き、 y か n の入力を待ち、 y のときに、 上のメソッドを使って削除します。

class KillBuffer < SwitchToBuffer
  def self.name() :kill_buffer end

  def edit(arg=nil)
    _, list = take_buffer_completion()
    name_default = window.buffer.name
    prompt = 'Kill buffer (default %s): ' % [name_default]
    name = ''
    screen.read_string(prompt, '', completion: list) do |str|
      case minibuffer.control
      when 1
        name = ('' == str) ? name_default : str
        buf = editor.get_buffer(name)
        if buf.nil?
          screen.minibuffer_pop()
          screen.miniwindow.print('', 'Kill buffer not found %s.' % [name])
        elsif ! buf.changed?
          screen.minibuffer_pop()
          screen.miniwindow.print('', 'Kill buffer %s.' % [name])
          editor.kill_buffer(name)
        else
          minibuffer.control = 2
          minibuffer.miniwindow.print(
            'Kill buffer %s changed. y or n ? (default n): ' % [name], '')
          minibuffer.completion = nil
          minibuffer.confirm = 'yn'
        end
      when 2
        if str == ''
          str = 'n'
        end
        screen.minibuffer_pop()
        if 'y' == str
          editor.kill_buffer(name)
        end
      end
    end
  end
end

これでエディタの導入部を記述することができるようになりました。 まず、 argv を getopt で解釈して、 パス名とプラス記号オプションによる行番号指定の組を files 配列に並べます。 続いて、 tty を初期し、 screen を作ります。 Screen の new のブロックで、 @screen に設定して get_buffer_create で screen 属性を使えるようにしてから、 files 配列から buffer を作り、 ファイルを読み込みます。 この導入部ではコマンドラインに同じファイル名が重複していてもエラーとはせず、 2 番目以降を無視します。 buffer_list の先頭と、 miniwindow 用バッファをブロックの戻り値にして、 screen の初期化をおこなわせます。 その後は、 画面表示・キーボード入力・コマンド実行を繰り返します。

#@<run>=
  def run(argv)
    opt = getopt(argv)
    if ! opt[:error].empty?
      raise OptionError, "invalid option " + opt[:error].join(" ")
    elsif opt[:version] || opt[:help]
      puts "version %s" % [VERSION] if opt[:version]
      puts "craft of text editing on ruby-lang." if opt[:help]
      return 0
    end
    # @kill_ring = KillRing.new(kill_ring_max)
    XTerm.open('/dev/tty') do |tty|
      tty.raw do
        tty.alternate_buffer do
          Screen.new(tty) do |scr|
            @screen = scr
            opt[:files].reverse_each do |path, line_number|
              buffer = get_buffer_create_from_path(path)
              if buffer.filename.empty?
                buffer.read_file(path)
                if line_number > 0
                  buffer.dot.point = buffer.forward_line(0, line_number - 1)
                end
              end
            end
            if buffer_list.empty?
              get_buffer_create('UNTITLED')
            end
            [buffer_list.first,
             Buffer.new(screen, ' *miniwindow*').flush_undo]
          end
          screen.miniwindow.buffer.mode = minibuffer_local
          read_evaluate_print_loop(tty)
        end
      end
    end
    0
  end

現在、 コマンド・ライン引数のオプションはプラス記号による行番号指定だけです。 コメントの例のように、 ファイルごとに行番号を指定することができます。

#@<getopt@>=
  # $ ruby SCRIPT +100 foo bar +120 baz
  #=> {:files => [["foo", 100], ["bar", 0], ["baz", 120]]}
  def getopt(argv, opt={})
    opt[:help] = opt[:version] = false
    opt[:error] = []
    opt[:files] = []
    opt_plus = 0
    i = 0
    while i < argv.size
      if %r/^[+](\d+)$/ =~ argv[i]
        opt_plus = $1.to_i
      elsif '--help' == argv[i]
        opt[:help] = true
      elsif '--version' == argv[i]
        opt[:version] = true
      elsif %r/^[+-]/ =~ argv[i]
        opt[:error] << argv[i]
      else
        opt[:files] << [argv[i], opt_plus]
        opt_plus = 0
      end
      i += 1
    end
    opt
  end

テキスト・エディタのインクリメンタル・サーチ

Emacs のインクリメンタル・サーチを真似してみます。 このサーチ法は、 1 文字入力するごとに、 パターンを 1 文字増やしながら同時に文字列を探します。 開始方法は 2 つあります。 isearch-forward (C-s) コマンドで現点から後方への検索を開始し、 isearch-backward (C-r) コマンドで前方への検索を開始します。 どちらにしても、 ISearchDialog のインスタンスを作成してキーボード入力を譲り渡します。 これのクラスは MiniBufferBase の派生クラスで、 tail にキーボード入力を戻すウィンドウを覚えています。 そして、 restart メッセージでインクリメンタル・サーチを開始します。

class ISearchForward < Interactive
  def self.name() :isearch_forward end

  def edit(arg)
    screen.activated_window_push ISearchDialog.new(window)
    minibuffer.restart(:search_forward)
  end
end

class ISearchBackward < Interactive
  def self.name() :isearch_backward end

  def edit(arg)
    screen.activated_window_push ISearchDialog.new(window)
    minibuffer.restart(:search_backward)
  end
end

キーボード入力とエコー領域への表示を ISearchDialog が担当し、 検索の仕組みは ISearchBuffer クラスが提供します。 ISearchBuffer は GapBuffer にインクリメンタル・サーチ機能をつけるためのラッピング・オブジェクトで、 Buffer の isearch 属性になっています。

class Buffer
  attr_reader :content, :isearch
  # 省略

  def initialize(name)
    # 省略
    @content = GapBuffer.new
    @isearch = ISearchBuffer.new(@content)
    # 省略
  end
end

ISearchBuffer は、 被検索バッファを content 属性に、 前に検索したパターンを history インスタンス変数に覚えておきます。 mode 属性は、 検索時用の局所的なモードです。 ここに検索中のコマンドとキー結合を定義しておきます。 restarted 以下の状態変数は、 エコー領域のプロンプトを Emacs 風の記述で表示するために使います。

class ISearchBuffer
  attr_reader :content, :pattern
  attr_accessor :mode

  def initialize(content)
    @content = content
    @mode = nil
    @journal = []
    @pattern = ''
    @history = ''
    @restarted = false
    @failing = false
    @wrapped = false
    @direction = @default = :search_forward
  end

isearch-forward (C-s) コマンドで始めたのか、 それとも isearch-backward (C-r) コマンドで始めたのかを記録して、 Undo (DEL) のためのジャーナリング・リストを空にして、 パターンも空文字列にします。

#@<インクリメンタル・サーチ開始@>=
  def restart(direction)
    @journal.clear
    @pattern.clear
    @restarted, @failing, @wrapped = true, false, false
    @direction = @default = direction
  end

検索の正常終了時に、 入力済みのパターンを @history インスタンス変数に覚えておきます。

#@<検索正常終了@>=
  def ok()
    @history = @pattern.dup
    @journal.clear
    @pattern.clear
  end

Undo (DEL) のためのジャーナリング・リストへ記録するのは、 現点、 パターン長、 3 つのプロンプト表示用状態変数です。

#@<ジャーナリング@>=
  def log(dot)
    @journal << [dot.point, @pattern.size, @failing, @wrapped, @direction]
  end

さて、 C-s C-s のように開始直後に開始時と同じキーをタイプすると、 覚えておいた @history インスタンス変数から入れ直します。 開始直後ではなく既にパターンを入力済みのときに search-repeat (C-s) か search-reverse (C-r) とタイプすると、 パターンを変更せずに再検索をかけます。 パターンが見つかってないときは、 C-s ならバッファの先頭へ戻り、 C-r ならバッファの末尾へ戻ってから再検索をかけます。 先頭や末尾へ戻ったときは wrapped 状態をオンにします。 log は後述するように、 インクリメンタル・サーチ専用のジャーナリング・ログを記録します。

#@<同じパターンで再検索@>=
  def again(dot, direction)
    log(dot)
    if @restarted && @default == direction
      @pattern = @history.dup
    end
    loc = dot.point
    if @failing && @direction == direction
      loc = :search_forward == direction ? 0 : content.size
      @wrapped = true
    end
    dot.point = search(content, loc, dot.point)
    if @failing
      @journal.pop
    end
  end

self-insert-command で文字をタイプすると、 パターンに追加します。 このとき、 追加後もパターンに一致し続けるときは、 一致点を動かしません。 一致しないときは次に一致する座標を探します。 その検索方向はインクリメンタル・サーチを始めたときのものに従います。

#@<パターンに一文字追加@>=
  def insert_char(dot, ch, count)
    log(dot)
    if failing?
      loc = dot.point
    elsif :search_forward == @default
      loc = dot.point - @pattern.size
    else
      loc = dot.point + @pattern.size
    end
    @pattern << ch
    dot.point = search(@default, loc, dot.point)
  end

パターンに追加できるのはキーボード入力からだけでなく、 yank-word (C-w) 一致している箇所の単語の後ろまでをパターンへ取り込むこともできます。 単語は空白で終わるものと決め打ちしています。 パターンへの取り込みは、 単語の終わりまで一度におこなうのではなく、 1 文字ずつ取り込んでいます。 こうすることで、 パターンの末尾の数文字を DEL で削ってタイプし直すことができるようにしています。

#@<現点以後の単語をパターンに取り込み@>=
  def yank_word(dot)
    @restarted = false
    not @failing or return
    loc = content.find_first_in_forward(" \n\t", dot.point, false)
    if dot.point < loc
      a = content[dot.point ... loc].grapheme_clusters
      while ! a.empty?
        log(dot)
        @direction = :search_forward
        @failing = false
        @pattern << a.shift
        dot.point += 1
      end
    end
  end

文字列検索は content に任せます。 search の引数は、 成功時と失敗時の座標を指定し、 どちらかが search の戻り値になります。

#@<パターンを検索@>=
  def search(direction, loc, fail_loc)
    @restarted = false
    @direction = direction
    if ! @pattern.empty? && (loc = content.send(direction, @pattern, loc))
      @failing = false
      loc
    else
      @failing = true
      fail_loc
    end
  end

content 属性は GapBuffer クラスのインスタンスでした。

class GapBuffer
#@<search_forward@>
#@<search_backward@>
  # 省略
end

文字列の検索は力任せ法でやっています。 ギャップの前で探し、 ギャップに重なる分を探し、 ギャップの後を探します。 見つかったら、 一致した文字列の右端の座標を返します。 見つからなかったときは nil を返します。

#@<search_forward@>=
  def search_forward(str, loc)
    not str.empty? or return loc
    gs, ge, gap = @gap_start, @gap_end, @gap_end - @gap_start
    n = self.size
    while true
      j = loc + str.size
      if n - loc < str.size
        return nil
      elsif loc + str.size <= gs
        return j if @data[loc, str.size] == str
      elsif gs <= loc
        return j if @data[loc + gap, str.size] == str
      else
        return j if @data[loc ... gs] + @data[ge ... loc + str.size + gap] == str
      end
      loc += 1
    end
  end

前向き検索も同じです。 一致した文字列の左端の座標を返すのが異なっています。

#@<search_backward@>=
  def search_backward(str, loc)
    not str.empty? or return loc
    gs, ge, gap = @gap_start, @gap_end, @gap_end - @gap_start
    loc = [loc + str.size - 1, self.size].min
    while true
      j = loc - str.size
      if loc < str.size
        return nil
      elsif loc <= gs
        break j if @data[j, str.size] == str
      elsif gs <= j
        break j if @data[j + gap , str.size] == str
      else
        break j if @data[j ... gs] + @data[ge ... loc + gap] == str
      end
      loc -= 1
    end
  end

DEL キーで実行する undo では、 状態変数を一つ前に戻して、 パターンを前の状態へ切り詰めます。

#@<undo@>=
  def undo(dot)
    if ! @journal.empty?
      dot.point, size, @failing, @wrapped, @direction = @journal.pop
      @pattern[size ... @pattern.size] = ''
    end
    @restarted = false
  end

C-g キーは、 途中までパターンと一致していて、 残りが不一致のときは、 一致している分までパターンを入力を巻き戻します。 というのが GNU Emacs のふるまいなのですが、 ここでは横着しているので、 パターンとの一致部分がなくて検索しているときは、 前に一致した文字列の位置へ巻き戻す DEL と同じふるまいをしてしまいます。

#@<パターンの不一致分を巻き戻す@>=
  def clean(dot)
    while ! @journal.empty? && @journal.last[2]
      @journal.pop
    end
    undo(dot)
  end

C-g キーで、 検索が成功しているときは、 検索そのものをおこなわなかったことにして、 検索を開始した座標へ現点を戻します。

#@<検索をなかったものにする@>=
  def revert(dot)
    dot.point = @journal.first[0] if ! @journal.empty?
    @journal.clear
    @pattern.clear
  end

ISearchDialog がプロンプトを組み立てるために状態変数を読めるようにしておきます。

#@<状態変数読み取り@>=
  def failing?()
    @failing
  end

  def wrapped?()
    @wrapped
  end

  def backward?()
    @direction == :search_backward
  end

続いて、 ISearchDialog です。 これは MiniBufferBase の派生クラスです。 buffer の isearch 属性をバッファに指定し、 このオブジェクトでラッピングされた Window を window 属性にしています。 コマンドと isearch とを適切に結びつけて、 isearch の状態変数からプロンプトを作っていきます。

class ISearchDialog < MiniBufferBase
  def initialize(window)
    super
    @inactive = true
    @auto_exit_minibuffer = true
  end

  def buffer() tail.buffer.isearch end
  def window() tail end
  def restart(direction) buffer.restart(direction) end
  def insert_char(ch, count) buffer.insert_char(dot, ch, count) end
  def redisplay_setup() miniwindow.print(prompt, buffer.pattern) end
  def redisplay_teardown() nil end

  def exit_minibuffer()
    buffer.ok()
    miniwindow.clear
    screen.activated_window_pop()
  end

  def abort_recursive_edit()
    if buffer.failing?
      buffer.clean(dot)
    else
      buffer.revert(dot)
      miniwindow.clear
      screen.activated_window_pop()
    end
  end

  def guess_key(command)
    print_key(command)
    screen.redisplay()
    screen.refresh
    activate
  end

  def print_key(command)
    miniwindow.print(prompt, '%s %s' % [buffer.pattern, command])
  end

  def unbound(command)
    miniwindow.print(prompt, '%s (Unbound %s)' % [buffer.pattern, command])
  end

private

  def prompt()
    t = ''
    t << 'Failing ' if buffer.failing?
    t << 'Wrapped ' if buffer.wrapped?
    t << 'I-search'
    t << ' backward' if buffer.backward?
    t << ': '
  end
end