AtCoder用のmixタスクを作ってみた

この記事は [Elixir Advent Calendar 2020](https://qiita.com/advent-calendar/2020/elixir) 17日目の記事です

昨日は @koga1020 さんで[「Refactoring a Function in Elixir」のblog postを読んでみよう](https://zenn.dev/koga1020/articles/d1ac2beab15132)でした！


------

9月に参加した [Elixir Digitalization Implementors #1](https://fukuokaex.connpass.com/event/188564/) にて、Elixirで[AtCoder](https://atcoder.jp)に参加できるということを教えていただきました :sparkles: 

今まで会社にも競技プログラミングやっている人たちがいて、面白いらしいということを聞いてはいたのですが、新しい言語勉強するのもなーって思い、参加していませんでした。。。
(去年に試しに一回だけコンテストやってみましたが、それっきりでした)


ただElixirで参加できるのなら、仕事でElixir書く機会もないし、やってみるかと思い改めて参加してみたところ、ドハマリし、現在 緑レート目指して精進中です :muscle: 

コンテストに参加していると、コードをテンプレートコードのコピペや実行などが面倒だと思ったので、mixタスクを書いてみました。

## 作成したもの

`ex_at_coder` という名前で公開しております :sparkles: 

**hex**
https://hex.pm/packages/ex_at_coder

**Github**
<a href="https://github.com/tamanugi/ex_at_coder"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/tamanugi/ex_at_coder.png" width="460px"></a>

## 簡単な機能紹介

今のところは以下の機能があります

- ログイン
- 提出用テンプレート作成
- 提出用コードの実行テスト

### インストール

hexに登録してあるので、以下を`mix.exs`に記述した上で`mix deps.get`でインストールすることができます。

```elixir:mix.exs
{:ex_at_coder, ">0.0.0"}
``` 

### ログイン

```
$ mix atcoder.login [username] [password]
```

指定された`username` `password`でAtCoderにログインします。

終了しているコンテストなどはログインしなくても閲覧できますが、開催中の場合はログインが必須になります。
ログインに成功した場合は、現在のディレクトリに`cookie.txt`が作成されます。

cookieをまるごとテキストファイルで保存しちゃっているのがあんまりよくない気がしますが、とりあえずこうしちゃっています :innocent: 


### 提出用コードの作成

```
$ mix atcoder.new [contest]
```

上記コマンドで指定したコンテスト用の提出ファイルの雛形とテストケースの作成を行います。
例えば AtCoder Regular Contest 109( https://atcoder.jp/contests/arc109 )でしたら
`arc109` と指定します

```
$ mix atcoder.new arc109
Generated ex_at_coder app
* creating lib/arc109
* creating lib/arc109/a.ex
* creating lib/arc109/test_case
* creating lib/arc109/test_case/a.yml
* creating lib/arc109/b.ex
* creating lib/arc109/test_case/b.yml
* creating lib/arc109/c.ex
* creating lib/arc109/test_case/c.yml
* creating lib/arc109/d.ex
* creating lib/arc109/test_case/d.yml
* creating lib/arc109/e.ex
* creating lib/arc109/test_case/e.yml
* creating lib/arc109/f.ex
* creating lib/arc109/test_case/f.yml
✨ Generate code for arc109
👍 Good Luck
```

コマンドを実行すると、提出用コードと実行テスト用のテストケースのyamlファイルを出力します。


問題は`https://atcoder.jp/contests/[contest]/tasks`のページから取得します。
提出ファイルの雛形に用いるテンプレートのEEXは以下の形で`config.exs`で指定することができます。

```elixir
config :ex_at_coder,
  template_path: "lib/template.eex"
```

```elixir
defmodule <%= @namespace %>.Main do
    def read_single() do
    IO.read(:line) |> String.trim() |> String.to_integer()
  end

  def read_array() do
    IO.read(:line) |> String.trim() |> String.split(" ") |> Enum.map(&String.to_integer/1)
  end

  def main() do
    n = 
      IO.read(:line)
      |> String.trim()
      |> String.to_integer()
   
    IO.puts(n)
  end
end
```

### 提出用コードの実行テスト

```
$ mix atcoder.test [contest] [problem]
```

指定したコンテスト、問題のテストケースを実行します。
デフォルトでは問題ページにある入出力サンプルをテストケースとして実行します

![スクリーンショット 2020-12-12 16.58.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55219/52c7e36a-a015-2a60-0761-9f15994c066b.png)


テストケースの入出力は `lib/[contest]/test_case` 以下にある yaml で設定できます。

```yml:lib/arc109/test_case/a.yml
- name: sample1
  in: |
    2 1 1 5
  out: |
    1

- name: sample2
  in: |
    1 2 100 1
  out: |
    101

- name: sample3
  in: |
    1 100 1 100
  out: |
    199

```


## 実装について

ここからが本記事のメインになります。
実装にあたって苦労したところは主に以下の点になります。

- ログイン状態の保持
- mixタスク上でのコードの実行
- mixタスク上で標準入力/出力を制御する

### ログイン状態の保持

`ex_at_coder`は `HTTPoison` + `Floki`の組み合わせで実装しました。
ログイン部分は以下のような実装になっています

```elixir
  @base_url "https://atcoder.jp"

  def login(username, password) do

    csrf_token =
      HttpClient.get(@base_url <> "/login")
      |> Floki.parse_document!()
      |> Floki.attribute("input[name=csrf_token]", "value")
      |> List.first()

    params =
      [
        username: username,
        password: password,
        csrf_token: csrf_token
      ]

    HttpClient.post(@base_url <> "/login", params)
    ...
  end
```

ログインフォームにアクセスし、CSRFトークンを探して、それをログインフォームのエンドポイントにPOSTリクエストし、得たcookieを使う・・・といった感じになります。

ただしここで少しハマったのがAtCoderのサイトは毎リクエストごとに `set-cookie`でセッションcookieを上書きしているようでした。

HTTPoisonは`set-cookie`を自動でいい感じにstoreしてくれる機能はありませんから、自前でどうにかする必要があります。

以下のforumに投稿されたコードを参考にリクエストごとにcookie.txtに吐き出すようにしてみました
[Help with HTTPoison POST - Elixir Forum](https://elixirforum.com/t/help-with-httpoison-post/11315/7)

これで一応ログイン状態は保持できるようになりました :sparkles: 

## 提出用コード/テストケースの作成

こちらは単純に問題ページの中からサンプルケースを探して、ymlに落とし込んでいるだけです。
yamlまわりに関しては以下のライブラリを使わせていただきました。

<a href="https://github.com/KamilLelonek/yaml-elixir"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/KamilLelonek/yaml-elixir.png" width="460px"></a>

ファイル生成については、以下の関数を使用しました。

[Mix.Generator.create_file/3](https://hexdocs.pm/mix/Mix.Generator.html#create_file/3)
[Mix.Generator.copy_template/4](https://hexdocs.pm/mix/Mix.Generator.html#copy_template/4)
このへんを使うといい感じに出力してくれるので楽ですね :sparkles: 

生成するコードのモジュール名は `defmodule [Contest].[Problem].Main do` の形式にしてしまっています。

AtCoderで提出するときは `defmodule Main do` のような形にする必要があるのですが、mixプロジェクト内で他のモジュールと名前がかぶってしまうとよくないので、コンテスト名.問題名をつけるようにしました。

## mixタスク上でのコードの実行

```
$ mix atcoder.test arc109 a
```
としたときに　`Arc109.A.Main.main()` を実行してあげたいです。

最初は単純に以下のように実行すればいいと考えていたのですが、

```elixir
:timer.tc(Arc109.A.Main, :main, [])
# 実行時間を取得したいので:timer.tc/3を使用する
```

他のmixプロジェクトにインストールして実行したときに問題がおきるようになってしまいました。

```elixir
:timer.tc(Arc109.A.Main, :main, [])
# 実行すると :undef エラーになってしまう
```

Elixirのコンパイル周りがどうなっているのか全然わかりませんが、おそらくmixタスクとして実行する際は大本のmixプロジェクトのモジュールはロードされていないのだと思います。

そこで`Module` `Kernel` モジュールなどそれっぽいモジュールを探したところ、使えそうなモジュールを発見しました

それが [Code](https://hexdocs.pm/elixir/Code.html)モジュールになります。
この中の `Code.require_file/1` を実行前に呼び出すようにしたところ、無事に実行されるようになりました :sparkles: 


```elixir
Code.require_file(file) # file: "lib/arc109/a.ex"
:timer.tc(Arc109.A.Main, :main, [])
# 実行された🎉
```

## mixタスク上での標準入力/出力の制御

AtCoderでは問題文の入力を標準入力で、解答を標準出力で行います。

例えば入力された数値を2倍するという問題があったときは以下のようなコードになります。

```elixir

defmodule Main do
  def main() do
    input = IO.read(:line) |> String.trim() |> String.to_integer
    IO.puts(input * 2)
  end
end
```

これをmixタスクでテストケースの確認を実行するときにymlに書かれた入力を標準入力として渡し、解答を標準出力から取得する必要があります

最初は以下のようなスタブを渡そうかと思いましたが、なんとなくすっきりしなく、別の方法をさがしました。

```elixir
defmodule Main do
  def main(io \\ IO) do
    input = io.read(:line) |> String.trim() |> String.to_integer
    io.puts(input * 2)
  end
end
# mix atcoder.text時には別のIOを渡す
```

そこでElixirのテストライブラリである`ExUnit`がそういえば標準出力をキャプチャしていた気がするなと思い、コードを覗いてみました。

それっぽいのは[この辺](https://github.com/elixir-lang/elixir/blob/master/lib/ex_unit/lib/ex_unit/capture_io.ex)ですね。


```elixir:lib/ex_unit/lib/ex_unit/capture_io.ex
  defp do_capture_io(:standard_io, options, fun) do
    prompt_config = Keyword.get(options, :capture_prompt, true)
    encoding = Keyword.get(options, :encoding, :unicode)
    input = Keyword.get(options, :input, "")

    original_gl = Process.group_leader()
    {:ok, capture_gl} = StringIO.open(input, capture_prompt: prompt_config, encoding: encoding)

    try do
      Process.group_leader(self(), capture_gl)
      do_capture_gl(capture_gl, fun)
    after
      Process.group_leader(self(), original_gl)
    end
  end
```

どうやら [StringIO](https://hexdocs.pm/elixir/StringIO.html) と [Process.group_leader]() が肝っぽいです。

というわけで、参考にしつつ以下のようにしてみました

```elixir
    original_gl = Process.group_leader()
    {:ok, io} = StringIO.open("1", capture_prompt: false, encoding: :unicode)
    Process.group_leader(self(), io)

    {runtime, output} =
      try do
        {runtime, _} = :timer.tc(Arc109.Main.A, :main, [])
        runtime
      catch
        _, _ ->
          {-1, :error}
      else
        runtime ->
          {:ok, {_input, output}} = StringIO.close(io)
          {runtime, output |> String.trim}
      after
        Process.group_leader(self(), original_gl)
      end

# output => "2"
```

標準入力/出力をコード上でコントロールすることができました :tada: 

おそらく以下のような流れで処理している気がします :thinking: 

1. `Process.group_leader()` で現在実行している処理の親プロセス?を取得
2. `StringIO.open/2` で疑似的なIOデバイスを作成
3. `Process.group_leader(self(), io)` で親プロセスを先程作成したIOデバイスに変える
4. 処理を実行
5. `StringIO.close(io)` で疑似IOデバイスを終了し、バッファに残っている文字列を取得する
6. `Process.group_leader(self(), originarl_gl)` でもとの親プロセスに戻す

※詳しい方いらっしゃいましたらやわらかいマサカリを投げていただけると嬉しいです！

これでコードの実行と実行結果の取得ができましたので、テストケースのyamlと照らしあわせ、正解かどうかを判定しています。


```elixir
{_, :error} -> IO.ANSI.red_background <> IO.ANSI.white <> " RE " <> IO.ANSI.reset()
{_, o} when o != test_out -> IO.ANSI.yellow_background <> IO.ANSI.black <> " WA " <> IO.ANSI.reset()
{r, _} when r > 2000_000 -> IO.ANSI.yellow_background <> IO.ANSI.black <> " TLE " <> IO.ANSI.reset()
_ -> IO.ANSI.green_background <> IO.ANSI.black <> " AC " <> IO.ANSI.reset()
```

ちなみに結果部分は上記のような`IO.ANSI`を使って色付けしています。
本当はもっとかっこいい出力にしたいのですが、僕ではこれが限界でした・・・ :cry: 

## まとめ

というわけで拙作 `ex_at_coder` の紹介をさせていただきました！
まだまだ機能不足＆色々不安定ですが、コメントやPRなどをいただけると嬉しいです！！


## AtCoderでのElixirについて

本題とは関係ないですが、最後にAtCoderでElixirを使う感想もちょろっと書こうと思います。
~~ぶっちゃけAtCoderでElixirの使用は向いてないと思います~~:cry::cry::cry::cry:

AtCoderで実行速度が遅いと言われているPython, Rubyあたりと比べても更に10倍以上遅い気がします。
AtCoderでは実行時間に制限があるため、これが非常に厳しいです。。。。

また競技プログラミングで使うアルゴリズムでは配列,Mapなどをよく使うのですが、Elixirではこれらのデータに対して `O(1)` でアクセスすることができません。
これが非常に厳しく `10^5 ~ ` オーダーのデータ管理が要求されるため、実行時間が膨らみがちでです。

まだそれほどの量の問題を解いたわけではありませんが、想定解どおりに実装しても実行時間オーバーになることも珍しくないような気がしています.

Elixirで解けないのが悔しすぎて、何を血迷ったか Elixir Forum に助けを求めたこともありました :joy: 
https://elixirforum.com/t/can-someone-solve-this-competitive-programming-problem-in-elixir/35252

.....ですが、やはりElixirのシンタックスは素晴らしく、パターンマッチングを使って(自分基準で)美しいコードで問題を解けたときなどは非常に達成感があります :sparkles: 

そもそもまだまだ実力不足なので、実際のコンテストに参加したときにElixirのせいで成績がふるわなかったということは今のところありません。。。。

さらにそもそもElixirで解けない問題があるというのも、Elixir愛が足りていないだけかもしれません :innocent: 
そこでもっともっとElixirを使ってAtCoderに参加してくれる人が増えてくれると、効率的な書き方が発見され、解ける問題が増えるかもしれません！！


ちなみに僕が現在Elixirでこれ無理だろって諦めてしまった問題は、例えば以下になります。
解いてくれる錬金術師をお待ちしております！！！！

- [三井住友信託銀行プログラミングコンテスト2019 D Luck Pin](https://atcoder.jp/contests/sumitrust2019/tasks/sumitb2019_d)
- [AtcoderBeginnerContest113 C ID](https://atcoder.jp/contests/abc113/tasks/abc113_c)
- [AtcoderBeginnerContest138 D Ki](https://atcoder.jp/contests/abc138/tasks/abc138_d)
- [AtCoder Beginner Contest 153 E - Crested Ibis vs Monster](https://atcoder.jp/contests/abc153/tasks/abc153_e)
- [Tenka1 Programmer Contest  C - 4/N](https://atcoder.jp/contests/tenka1-2017/tasks/tenka1_2017_c)