dRubyでemacsclient的なものを作る

Posted by Shugo Maeda on January 29, 2019 · 4 mins read

NaClの前田です。

今年はdRuby 20周年です。というわけで、dRubyを使ってTextbringerのためのemacsclient的なものを作ってみたので紹介します。

emacsclientとは

emacsclientはすでに動作しているEmacsのプロセスでファイルを開いたり、Emacs Lispを実行するためのコマンドです。

Emacs側でM-x server-startした上で、

$ emacsclient <ファイル名>

とすると、Emacs側で指定したファイルが開かれ、emacsclientは終了を待ちます。
EmacsでC-x #とタイプすると編集が終了し、emacsclientも終了します。

$ emacsclient -n <ファイル名>

とすると、編集終了を待たずにすぐに戻ってきます。

# emacsclient -e '(message "foo")'

とすると、Emacs上でEmacs Lispが実行されます。

dRubyとは

dRubyは、(場合によっては他のマシンの)他のプロセスのオブジェクトに対してメソッド呼び出しを行うためのライブラリです。プロトコルを考えたり、APIの定義ファイルを用意したりといった手間がないので、双方のプログラムがRubyで書かれていれば簡単に協調することができます。

ただし、インターネット上でサーバを公開するような用途は想定していませんので、注意が必要です。

先日松江で行ったワークショップスライド配布資料が公開されていますので、そちらも合わせて参照ください。

サーバ側の実装

サーバ側の実装は以下のとおりです。

require "drb"

module Textbringer
  module Commands
    define_command(:server_start,
                   doc: "Start Textbringer server.") do
      uri = CONFIG[:server_uri] ||
        "drbunix:" + File.expand_path("server.sock", "~/.textbringer")
      options = CONFIG[:server_options] || { UNIXFileMode: 0600 }
      DRb.start_service(uri, Server.new, options)
    end

    define_command(:server_kill,
                   doc: "Kill Textbringer server.") do
      DRb.stop_service
    end

    define_command(:server_edit_done,
                   doc: "Finish server edit.") do
      queue = Buffer.current[:client_wait_queue]
      if queue.nil?
        raise EditorError, "No waiting clients"
      end
      if Buffer.current.modified? &&
          y_or_n?("Save file #{Buffer.current.file_name}?")
        save_buffer
      end
      kill_buffer(Buffer.current, force: true)
      queue.push(:done)
    end
  end

  class Server
    def eval(s)
      with_redisplay do
        Controller.current.instance_eval(s).inspect
      end
    end

    def visit_file(filename, wait: true)
      queue = Queue.new if wait
      with_redisplay do
        find_file(filename)
        Buffer.current[:client_wait_queue] = queue if wait
      end
      queue.deq if wait
    end

    private

    def with_redisplay
      foreground! do
        begin
          yield
        ensure
          Window.redisplay
        end
      end
    end
  end
end

ポイントは

  • DRbは最後だけ小文字(重要)。
  • DRb.start_serviceでサーバのURIと公開するオブジェクトを指定する。
    デフォルトはパーミッション600でUNIXドメインソケットを作成するようにしています。
  • evalの結果はサーバ側でinspectする(マーシャルできないオブジェクトはクライアントにリモートの参照が返されるため)。
  • visit_fileで編集の終了を待つためにQueueを使用する。
    咳さんのワークショップでは、待ち合わせと情報の交換を同時に行えるのがQueueの特徴と解説されていましたが、この例では待ち合わせをしたいだけなので意味のない値(:done)を渡しています。
  • Textbringerのコマンドを実行する時はforeground!を使用する(dRubyのメソッド呼び出しはTextbringerのメインスレッドと別のスレッドで実行されるため)。
  • Window.redisplayで画面の再描画を行う。

といったあたりです。

クライアント側の実装

クライアント側の実装は以下のとおりです。

require "drb"
require "optparse"

eval = false
wait = true
uri = "drbunix:" + File.expand_path("server.sock", "~/.textbringer")

opt = OptionParser.new
opt.banner = "Usage: tbclient [OPTIONS] FILE"
opt.on("--uri URI", "Specify the URI of the server") do |val|
  uri = val
end
opt.on("-e", "--eval", "Evaluate FILE as a Ruby expression") do
  eval = true
end
opt.on("-n", "--no-wait", "Don't wait for the server") do
  wait = false
end
args = ARGV.dup
opt.parse!(args)
if args.empty?
  STDERR.puts(opt.help)
  exit 1
end
arg = args.first
tb = DRbObject.new_with_uri(uri)
if eval
  puts tb.eval(arg)
else
  tb.visit_file(File.expand_path(arg), wait: wait)
end

ポイントは DRbObject.new_with_uri(uri) でサーバ上のオブジェクトの参照を得ることくらいで、あとは普通のオブジェクトのようにメソッドを呼び出すだけです。簡単ですね!

今回の例ではクライアント側ではDRb.start_serviceを呼んでいませんが、サーバ側にマーシャルできないオブジェクトを渡す(リモートオブジェクトのメソッド呼び出しの引数で与える)時は、クライアント側でもDRb.start_serviceが必要になります。

まとめ

dRubyを使うことで、emacsclientのようなものを簡単に作ることができました。

やはりテキストエディタをRubyで拡張できると便利ですね。