Vim scriptでAWS Lambdaも書ける！

本記事は [Vim その2 アドベントカレンダー](https://qiita.com/advent-calendar/2018/vim2) 20日目の記事です。

先日、AWS re:Invent 2018 にてAWS Lambda上でCustom Runtimeが発表されました。
弊社でも早速Rustの公式ランタイムを使ってみたりして、いろいろな言語で使える展望を感じてワクワクしていました。

でも、せっかくいろいろなものが動かせるのなら、どうせならあんまり意図されていないようなものに挑戦してみたほうがCustom Runtimeへの理解も深まるかなぁと思い、今回はVimをCustom Runtime上に設置して、Vim scriptでRuntimeのハンドラーを書けるようにならないかどうか挑戦してみて、一応Vim scriptでハンドラーをかいてLambdaのレスポンスとして返せるようになりました。

成果物:
https://github.com/pocket7878/aws-lambda-vim-layer
サンプルコード:
https://github.com/pocket7878/aws-lambda-vim-layer-example

# Custom Runtimeのざっくりした構造

Custom Runtimeは、適切なディレクトリに実行したいバイナリやライブラリと`bootstrap`というエントリの実行ファイルを配置したzipファイルで、`bootstrap`ファイルがLambdaの起動時に自動的に呼び出されるという仕組みのものです。
このbootstrap内で、今回発表されたRuntime APIからイベントをGETして、結果を結果投稿用のAPIにPOSTしてやるようなループ処理ができればどんなものでもLambdaのRuntimeとして利用できます。

# Vimと依存ライブラリの取得

今回はVimをLambdaの環境で動かしたいため、[lambci/lambda](https://hub.docker.com/r/lambci/lambda/)のDocker Imageを利用してAWS Lambdaの内部環境を再現した環境で動くVim及びVimの実行に必要なライブラリの取得を行っています。

仕組みとしては単純にlambciベースのDocker Imageの中でvimをパッケージ経由でインストールしてやり、そのvimのバイナリとvimの依存しているライブラリ等を起動時にマウントしておいたホストのディレクトリにコピーしてきています。
(ここはvimのソースを実際にダウンロードしてきてコンパイルしてくる形にして、外部からビルドオプション等を指定できるようにしたいと思っていますが、今回はまずは完動を目指して後回しにしています）

# vimrcの作成

vimをコピーしてきただけではまだvimのバイナリが入ったLambda Layerにしかならないので、
bootstrapファイルを作成して、指定されたVim scriptファイルを読み込んで、ハンドラー関数からの結果を返せるようにしなければなりません。そこで今回は、LambdaのRuntimeAPIを叩くループを行う関数を起動直後に呼び出すvimrcファイルを作成し、そのvimrcを指定してvimを起動する処理をbootstrapファイルに書いています。

```shell
vim -u /opt/vim/vimrc
```

## `function! LambdaHandler()`

AWS Lambdaのハンドラ指定は`<ハンドラファイル名>.<関数名>`という形式になっているため、
ディレクトリ内の`<ハンドラファイル名>.vim`というディレクトリをsourceしてやって、
whileループでひたすらRuntime APIのイベントGETのエンドポイントを叩いてはイベントデータとコンテキストを関数に渡してやって、
返ってきた結果をまたRuntime APIの結果を返すエンドポイントにPOSTしてやるという関数を定義しています。

```vim
function! LambdaHandler()
  let V = vital#lambda_vim_layer#new()
  let HTTP = V.import('Web.HTTP')
  let JSON = V.import('Web.JSON')
  let handlerInfo = split($_HANDLER, '\.')
  let handlerFile = $LAMBDA_TASK_ROOT . "/" . handlerInfo[0] . ".vim"
  let handlerMethod = handlerInfo[1]
  if filereadable(expand(handlerFile))
    execute 'source ' . fnameescape(handlerFile)
  endif
  while 1
    let nextEvent = HTTP.get("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/next")
    let eventData = JSON.decode(nextEvent.content)
    let eventHeader = HTTP.parseHeader(nextEvent.header)
    let eventContext = {
          \'memoryLimitInMb' : $AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
          \'functionName' : $AWS_LAMBDA_FUNCTION_NAME,
          \'functionVersion' : $AWS_LAMBDA_FUNCTION_VERSION,
          \'invokedFunctionArm' : eventHeader['Lambda-Runtime-Invoked-Function-Arn'],
          \'xrayTraceId' : eventHeader['Lambda-Runtime-Trace-Id'],
          \'awsRequestId' : eventHeader['Lambda-Runtime-Aws-Request-Id'],
          \'logStreamName' : $AWS_LAMBDA_LOG_STREAM_NAME,
          \'logGroupName' : $AWS_LAMBDA_LOG_GROUP_NAME,
          \'clientContext' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Client-Context', '{}')),
          \'identity' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Cognito-Identity', '{}')),
          \}
    let requestId = eventHeader['Lambda-Runtime-Aws-Request-Id']
    let result = function(handlerMethod)(eventData, eventContext)
    call HTTP.post("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/".requestId."/response", result)
  endwhile
endfunction
```

LambdaHandler()ではWebのAPIを叩くことになるのでvital.vimを使いたかったので、
zipのなかにvital.vimを適用したプラグインの入ったbundleディレクトリを置いておいてやり

```viml
execute 'set rtp+=' . expand('<sfile>:p:h') . '/bundle'
```

のようにしてruntimepathに追加しています。

この関数がvimの起動直後に呼び出されるようにしたいので、

```vim
autocmd VimEnter * call LambdaHandler()
```

で登録して一通り初期化処理がおわったらLambdaHandler()が呼び出されるように指定しています。

## vimの$VIMRUNTIMEディレクトリのコピー

バイナリと上記vimrcでも一応vimとしては動くのですが、標準で付属してくるランタイムも同梱しなくては気持ちよくVim scriptでハンドラファイルが書けません。

```shell
VIM_VIMRUNTIME=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo $VIMRUNTIME|quit' | tr -d '\015' `
VIM_RUNTIMEPATH=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo &runtimepath|quit' | tr -d '\015' `
```
のようにして、インストールされたvimの`$VIMRUNTIME`及び`runtimepath`を取得します。

`runtimepath`のディレクトリを一つ一つホストのディレクトリにコピーしていきます。
このとき、コピーしたディレクトリ一つ一つをruntimepathに追加するようなコードをvimrcに追記していきます。そして、もしコピーしたディレクトリが`$VIMRUNTIME`に指定されているものだったら、そのコピー先を`$VIMRUNTIME`に指定するようなコードも合わせて追記していきます。


### ちょっとした苦労

`runtimepath`はコンマで区切られたディレクトリが入っていますが、`<runtime_dir>,<runtime_dir>/after`のように親子関係になったディレクトリも入っていますので、順番に単純にコピーしてきてしまうと重複してコピーされてしまいますので、処理しながら他の`<runtime_dir>`をコピーしたタイミングで一緒にコピーされることになるものは無視する必要があります。そのあたりをなるべくシェルスクリプトだけでやりたかったので、ナイーブに正規表現で判定していますが、もう少し良い方法がありそうです..

## ユーザーもプラグインを同梱できるように

これで、基本的なVIMRUNTIMEに入っているものは扱えるレイヤーが出来上がりますが、これだけではレイヤーを使う側のユーザーが独自で追加したいプラグイン等を追加することができません。
なので、最後に決め打ちでハンドラファイルが入っているのと同じディレクトリ内の`vim/bundle`というディレクトリもruntimepathに含めるようにしてやります。

```viml
execute 'set rtp+=' . expand($LAMBDA_TASK_ROOT . "/vim/bundle")
```

このようにしておくと、レイヤーを使う側でvim/bundle内にautoload等のフォルダを置いておくと通常のvimと同様にautoloadの対象に含まれるようにできます。

これで、最終的にランタイム等の設定についても書かれたvimrcファイルが無事できあがります。
それらをまとめてzipファイルにして、レイヤーとしては完成です。

# 利用例(TOHtml)

たとえばハンドラー関数として`handler.Method`を指定しておいて
以下のように、ユーザーからきたイベントの`content`の内容をバッファに展開して、
指定されたカラースキームとファイルタイプを適用した状態でTOHtmlした結果のバッファを読み込んでやって
結果として返す関数を定義しておきます。

```viml:handler.vim
function! Method(event, context)
  let content = a:event.content
  enew!
  put =content
  1delete _
  execute 'syntax on'
  execute 'set ft='.a:event.filetype
  execute 'colorscheme '.a:event.colorscheme
  runtime syntax/2html.vim
  execute bufwinnr(bufnr('Untitled.xhtml'))'.wincmd w'
  let line_ending = "\n"
  let text = join(getline(1, '$'), line_ending).line_ending
  q!
  return text
endfunction
```

すると、たとえばイベントとして以下のようなものを送ってやると

```event.json
{
  "filetype": "c",
  "colorscheme": "blue",
  "content": "#include <stdio.h>\n\nint main(int argc, char **argv) {\n\tprintf(\"Hello, world\\n\");\n\treturn 0;\n}"
}
```

このように、Lambdaから無事TOHtmlした結果が帰ってくるようになります:
<img width="1301" alt="スクリーンショット 2018-12-20 2.18.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/14359/be621f9d-e954-a755-b4a0-044e55e3aac2.png">

たとえばここで`vim/bundle/colors`内にお好みのカラースキームを同梱しておけば、標準でないカラースキームを適用した結果を得ることもできるようになると思います。

# まとめ

Vim scriptでAWS Lambdaも書ける！