AtomでVimのモードラインを限定的に解釈する


## はじめに

Vimにはモードラインという、ファイル自体に記述したVimの各種オプションを読み取り、ファイルを開く際に自動で設定を行う機能がある。
例えば下記のようなコメントをRubyプログラムの先頭または末尾あたりに書いておくとする。

```ruby
# vim:set expandtab tabstop=2 fenc=euc-jp ff=unix ft=ruby:
```

すると、このファイルを開く際、Vimは自動で

- Tabの代わりに空白2文字を挿入
- 文字コードはeuc-jp
- 改行コードはLF
- ファイルタイプはRuby

とエディタの設定を変更してくれる。

詳しくは[マニュアルの日本語訳](https://github.com/vim-jp/vimdoc-ja/blob/master/doc/options.jax#L499)を読んでいただきたいが、VimからAtomに移行した際にはこのモードラインを解釈してほしいケースがときたま存在する。
物は試しとモードラインの解釈から設定の反映までを試みることにした。

もちろん、VimとAtomでは設定項目が異なってくるため、ひとまずは個人的によく使う「文字コード」「改行コード」「ファイルタイプ」の設定を行うことを目標とする。

## モードラインのパース

モードラインの書式は上記のマニュアル内に記載があるため、それらに沿った書式を正規表現でパースすることとした。

```coffeescript
parseVimModeLine = (line) ->
  regexp = /(vi|vim|\s+ex):\s*(se(t)*)*\s+([^:]+)*\s*:/
  matches = line.match regexp
  options = null
  if matches
    options = {}
    for option in matches[4].split(" ")
      [key, value] = option.split "="
      options[key] = value ? true if key != ""
  options
```

「はじめに」に記載した例を上記関数に渡すと下記のようなオブジェクトを返してくれる。

```coffeescript
{expandtab: true, tabstop: "2", fenc: "euc-jp", ff: "unix", ft: "ruby"}
```

## 文字コードのセット

[encoding-selector](https://atom.io/packages/encoding-selector)の`detectEncoding`を参考に、文字コードのセットを行う部分だけを取り出した。
[iconv-lite](https://github.com/ashtuchkin/iconv-lite)が対応している文字コードしか通せないため、例えば`eucJP-ms`と記載されていた場合は`false`を返すこととなる。

```coffeescript
setEncoding = (encoding) ->
  iconv = require 'iconv-lite'
  editor = atom.workspace.getActiveTextEditor()
  return false unless iconv.encodingExists(encoding)
  encoding = encoding.toLowerCase().replace(/[^0-9a-z]|:\d{4}$/g, '')
  editor.setEncoding(encoding)
```

上記`parseVimModeLine`の返り値の`fileencoding`または`fenc`の値を渡せば設定可能となる。

```coffeescript
options = parseVimModeLine(line)
setEncoding(options.fileencoding ? options.fenc)
```

上記のような書き方の場合、`fileencoding`、`fenc`どちらも設定がない場合は`undefined`が渡ることとなるが、特にエラーが発生する訳でもないのでプロパティの存在チェックは行っていない。

## 改行コードのセット

[line-ending-selector](https://github.com/atom/line-ending-selector)の実装を参考に改行コードの設定を行う関数を実装した。
設定と同時にバッファ内の改行コードを置換するようにしているため、実際の改行コードとモードラインに記載された改行コードが異なる場合は多少注意が必要となる。

```coffeescript
setLineEnding = (lineEnding) ->
  editor = atom.workspace.getActiveTextEditor()
  buffer = editor?.getBuffer()
  return unless buffer
  buffer.setPreferredLineEnding(lineEnding)
  buffer.setText(buffer.getText().replace(/\r\n|\r|\n/g, buffer.getPreferredLineEnding()))
```

モードラインに記載するものは`unix`、`dos`、`mac`という記法であるため、これらと実際の改行コードを結び付けてやる必要がある。

```coffeescript
format   = options.fileformat ? options.ff
lineEnding: {
  unix: "\n"
  dos:  "\r\n"
  mac:  "\r"
}
setLineEnding(lineEnding[format])
```

なお、`CR`はline-ending-selectorの選択肢には表示されていないが、試してみたところ正しく設定がされ、ステータスバーの改行コード欄も`CR`と表示されていた。

## ファイルタイプのセット

**2015/11/09 `atom.grammars.selectGrammar`を利用するように修正**

[grammar-selector](https://github.com/atom/grammar-selector)の実装を参考にファイルタイプの設定を行う関数を実装したのだが、`atom.grammars.selectGrammar`といううってつけのメソッドが存在するため書き換えた。
この場合、例えばCore Packagesの[language-ruby-on-rails](https://atom.io/packages/language-ruby-on-rails)のものである`Ruby on Rails`は選択されず、railsプロジェクトであっても`Ruby`が選択されてしまうこととなる。
今のところAtomでrailsプロジェクトを扱う予定はないのでこのまま利用しようと思うが、railsプロジェクトと通常のRubyプログラムを書き分ける際は何かしら解決策を考える必要がある。

```coffeescript
setFileType = (type) ->
  editor = atom.workspace.getActiveTextEditor()
  return unless editor
  grammar = atom.grammars.selectGrammar(type)
  if grammar isnt atom.grammars.nullGrammar
    atom.grammars.setGrammarOverrideForPath(editor.getPath(), grammar)
    editor.setGrammar(grammar)
```

利用する際はパースした`filetype`、`ft`の値をそのまま引数に渡すようにしたが、場合によっては読み替えを行うようにする必要がある。

```coffeescript
setFileType(options.filetype ? options.ft)
```

現時点ではこれで困らなかったのでそのまま利用しているが、必要ならば「改行コードのセット」のように読み替え用のオブジェクトを用意し、`undefined`であれば`filetype`に設定されているものをそのまま渡すようにするとよいと思う。

## ファイルの読み込み時に実行する

**2015/11/09 `atom.grammars.selectGrammar`を利用するように修正**

上記の関数を`init.coffee`に記載し、ファイルの読み込み時に実行されるようにする。

```coffeescript
atom.workspace.onDidOpen ->
  unless detectVimModeLine()
    detectEncoding()

detectVimModeLine = ->
  editor = atom.workspace.getActiveTextEditor()
  lineEnding: {
    unix: "\n"
    dos:  "\r\n"
    mac:  "\r"
  }
  try
    firstLine = editor.lineTextForBufferRow(0)
    lastLine  = editor.lineTextForBufferRow(editor.getLastBufferRow())
  catch error
    return false
  options = parseVimModeLine(firstLine) ? parseVimModeLine(lastLine)
  return false unless options
  encoding = options.fileencoding ? options.fenc
  format   = options.fileformat ? options.ff
  type     = options.filetype ? options.ft

  setLineEnding(lineEnding[format])
  setFileType(type)
  unless setEncoding(encoding)
    detectEncoding()
  return true

parseVimModeLine = (line) ->
  regexp = /(vi|vim|ex):\s*(se(t)*)*\s+([^:]+)*\s*:/
  matches = line.match regexp
  options = null
  if matches
    options = {}
    for option in matches[4].split(" ")
      [key, value] = option.split "="
      options[key] = value ? true if key != ""
  options

setEncoding = (encoding) ->
  iconv = require 'iconv-lite'
  editor = atom.workspace.getActiveTextEditor()
  return false unless iconv.encodingExists(encoding)
  encoding = encoding.toLowerCase().replace(/[^0-9a-z]|:\d{4}$/g, '')
  editor.setEncoding(encoding)
  return true

setLineEnding = (lineEnding) ->
  editor = atom.workspace.getActiveTextEditor()
  buffer = editor?.getBuffer()
  return unless buffer
  buffer.setPreferredLineEnding(lineEnding)
  buffer.setText(buffer.getText().replace(/\r\n|\r|\n/g, buffer.getPreferredLineEnding()))

setFileType = (type) ->
  editor = atom.workspace.getActiveTextEditor()
  return unless editor
  grammar = atom.grammars.selectGrammar(type)
  if grammar isnt atom.grammars.nullGrammar
    atom.grammars.setGrammarOverrideForPath(editor.getPath(), grammar)
    editor.setGrammar(grammar)

detectEncoding = ->
  editor = atom.workspace.getActiveTextEditor()
  try
    filePath = editor.getPath()
  catch error
    return
  return unless fs.existsSync(filePath)

  jschardet = require 'jschardet'
  fs.readFile filePath, (error, buffer) =>
    return if error?
    {encoding} = jschardet.detect(buffer) ? {}
    encoding = 'utf8' if encoding is 'ascii'
    setEncoding(encoding)
```

`detectEncoding`はencoding-selectorの`detectEncoding`のうち、文字コード判定箇所のみを抜き出したものとなる。
モードラインによる判定に失敗した場合は`false`を返すようにしているので、モードラインそのものが設定されていなかったり、利用できない文字コードが設定されている場合はファイルの内容を読んで文字コードを判定している。
また、本来のモードラインの仕様であれば先頭または末尾の数行を読むようになっているが、この実装では先頭1行、末尾1行しか読んでいない。
これは筆者の環境では先頭または末尾にしか書いていないからなので、本来の実装に沿うのであればこの部分を書き換える必要がある。