ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

parse.y を読み解いて、Ruby の文法を理解する

イントロダクション

世はまさに大パーサー時代。人々はパーサーの海へと繰り出す。 ――― kaneko.y

RubyKaigi 2023 における "the bison killer" による大パーサー時代の幕開け宣言からはや4ヶ月が経ちました。みなさまいかがお過ごしでしょうか。@junk0612 です。

タイトルにも記載したとおり、今回は parse.y をちょっとわかったつもりになれるかもしれない記事です。 あらためて軽く説明すると、parse.y とは、Ruby の文法とその処理内容について定義されたファイルです。難解なことで有名になってしまっていますが、実際に軽い気持ちで手を出して、意味も分からず圧倒された経験がこれを読んでいる方にもあるのではないでしょうか。かくいう僕もそうでした。

ですが、パーサージェネレーターである Bison (== Lrama) の文法ファイルの書き方がわかっていれば、言い換えれば、どこに何が書かれているのかがわかっていれば、理解したい内容の部分に絞って読むことができるのでかなり理解しやすくなります。今回は Ruby の文法構造を例にとってみます。どれくらいわかりやすくなるか、注目しながら読んでみてください。

入門・Bison 文法ファイルの大まかな理解

なにはともあれ、Bison の文法ファイルがどうなっているか、ざっくりとした把握から始めていきましょう。まず、文法ファイルの構造の概要を Bison のドキュメント から引用すると、下記のとおりです。

%{
  Prologue
%}

Bison declarations

%%
Grammar rules
%%

Epilogue

一番大きなくくりでは、Bison の文法ファイルを構成しているのはこの4つのセクションだけです。また、各セクションは記号で分けられており、実際に parse.y の内部を検索してみると、この記事を書いている時点の master では

にあります。

この中で文法はどこに書かれているかというと、Grammar rules セクションです。つまり %% の間だけ見ればいいので、それ以外の約 10,000 行を消すことができます。ということで、さくっと消してしまいましょう。消したものがこちらです。

本編・Grammar の理解

さて、続いては Grammar rules の内部の構造を軽く見ていきましょう。Grammar rules は、下記のような「文法」を任意の数だけ連ねたものとしてできています。

nterm: symbol1 symbol2 symbol3 ...
     | symbolA symbolB symbolC ... { action }
     | symbolX { action1 } symbolY { action2 } ...

これは、以下の内容を意味しています。

  • この文法で生成される記号は nterm という名前である
  • nterm は、以下のいずれかの記号がこの順で並んでいるものである
    • symbol1 symbol2 symbol3 ...
    • symbolA symbolB symbolC ...
    • symbolX symbolY ...
  • { } でくくられた action を使って、nterm が出来上がった時点でどのような処理を行うかを記述している
    • Action がない場合、Bison は入力された文字列が nterm として正しいかどうか判定することだけ行う。AST を作ったりはしない
  • 文法の記述途中にも Action を記載することができる。action1symbolX を読み込んだ直後、action2symbolY を読み込んだ直後に実行される

いろいろ書き連ねましたが、大事なのは Action は、文法そのものとは関係がない ということです。言い換えれば、Action を消してしまっても、Ruby の文法構造は影響を受けないということになります。

今回は文法の理解に焦点を当てるので、不要な Action は消してしまいましょう。数が多いので、もりもり消すことができて、消したものがこちらになります。

どうでしょう? ここまで消すと、ちょっと読めるかもという気がしてきませんか?

Bison の文法の読み方は上でお話ししたとおりなので、ちょっと読んでみましょう。

stmt       : keyword_alias fitem  fitem
                | keyword_alias tGVAR tGVAR
                | keyword_alias tGVAR tBACK_REF
                | keyword_alias tGVAR tNTH_REF
                | keyword_undef undef_list
                | stmt modifier_if expr_value
                | stmt modifier_unless expr_value
                | stmt modifier_while expr_value
                | stmt modifier_until expr_value
                | stmt modifier_rescue stmt
                | keyword_END '{' compstmt '}'
                | command_asgn
                | mlhs '=' lex_ctxt command_call
                | lhs '=' lex_ctxt mrhs
                | mlhs '=' lex_ctxt mrhs_arg modifier_rescue stmt
                | mlhs '=' lex_ctxt mrhs_arg
                | expr
                | error
                ;

例えばこれは、stmt という記号に対する文法ですが、最初の4行は fiterm tGVAR tBACK_REF tNTH_REF などが何を指すのかよく分からなくても、keyword_alias から察するに alias のことを指していそうだ、というのはわかると思います。同様にその次の1行は undef、その次の5行は後置 if、後置 unless などの後置記法を指していそう、ということもなんとなく推測できると思います。

文法中に現れる各記号は、別の文法の左辺として現れる非終端記号か、実際の文字列に1対1で対応する終端記号のどちらかに必ず分類されます。stmt を例に取ると、keyword_alias は終端記号として parse.y の L1571 に定義されていますし、mrhs は非終端記号として別の場所に文法が定義されています。

このように文法の「展開」を繰り返して、最終的にソースコードの文字列に対応させるまでの文法をすべて記述することが、Bison の文法ファイルの中心的な機能であり、Ruby の文法を理解する上で一番重要なポイントになります。これさえ分かってしまえば、あとは読むか読まないかです。あなたもレッツ読解!

余談・Action のためにある文法の名寄せ

文法部分だけを抽出した parse.y を読んでいると、たまに別の記号と1対1で対応付けるだけの文法があることに気づきます。たとえばこのあたりなどです。

これらの存在している理由は、文脈によって異なる Action を書くためです。主に構文解析時の都合で、たとえば普通の if 式の if と後置 if の if では、読み込んでいる状態が異なるために別の処理をしなければならないということがよく起こります。このときに、別の文法として2つの非終端記号を定義し、片方は特例の処理を実装しておいてもう片方の記号を呼ぶようにしておくと便利に使えます。

ですが、あくまで文法理解の観点から見れば、これらは同じキーワードです。2つある文法を名寄せして1つにまとめてしまっても全体としては変わりません。今回は名寄せしたものは用意していませんが、上にも載せた文法のみのファイルからスタートして、試しにいじってみてはいかがでしょうか。Ruby の文法の理解に役立つと思います (一部の奇特な方には TRICK のネタになるかも?)。

終わりに

いかがでしたでしょうか。今回は、Ruby の文法理解に焦点を当てて parse.y を読んでいくにはどうするかをご紹介しました。 kaneko.y さんの書いたパーサーやりたいことリストにはいろんな内容があるので、一人でも多くの方に協力していただけると、世界がちょっとずつ良くなっていきます。この記事を読んで「意外といけるかも……」と思った方がいたら、ぜひ ruby-jp Slack の #lr-parser チャンネルへお越しください。加えて、弊社に入社いただければ構文解析器研究部員の肩書きもご用意しております。

永和システムマネジメントでは、パーサをいじってわいわい遊びたい方の応募をお待ちしております。

agile.esm.co.jp

中高生国際Rubyプログラミングコンテストに今年もゴールドパートナーで協賛します

これからの社会を担っていくことになる世代に向けての Ruby プログラミングコンテストである『中高生国際Rubyプログラミングコンテスト2023 in Mitaka』に、今年もゴールドパートナーとして協賛します。

www.ruby-procon.net

昨年もゴールドパートナーとして最終審査会に参加しましたが、提出された作品がどれもおもしろくできていて、企業賞をどのチームにお渡しするか楽しく悩みました。今年も、素晴らしい作品が出てくることを期待しています。参加者のみなさんは作品提出期限がまもなくで追い込みの時期かと思いますが、最後まで頑張ってください。

アジャイルと Ruby が実現するソフトウェア開発は、開発者が「楽しさ」を感じられる開発であり、そこにはきっとビジネス価値がある――私たちはそう信じて行動を続けています。同じように、プログラミングを楽しくする Ruby を通じて実現される、中高生の作品を楽しみにしています!


株式会社永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと共生しながら成長したいエンジニアを絶賛募集しています。

agile.esm.co.jp

Rails / OSS パッチ会オンライン 2023年9月のお知らせ

2023年9月の Rails / OSS パッチ会を 9月28日(木)に Discord でオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS について、upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。

Discord の Rails/OSS パッチ会サーバーへの招待 URL は以下です👇

discord.gg

Rails 7.1.0.beta1 や Kaigi on Rails 2023 などに関する話題があるかもしれません。

これからパッチ会に参加してみたいという方や、OSS 開発者間の会話に興味があるので聞いてみたいという方もお気軽にどうぞ。


永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと成長したいエンジニアを絶賛募集しています。

agile.esm.co.jp

XP祭り 2023 にて永和システムマネジメントは3セッションを持ちます

2023年9月30日(土) にオンライン開催される XP祭り 2023 にて、永和システムマネジメントは3セッションを持ちます。

弊社メンバーの登壇スケジュールとタイトルは以下です。

お時間のあう方はぜひ遊びに来てください。

xpjug.connpass.com


株式会社永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと共生しながら成長したいエンジニアを絶賛募集しています。

agile.esm.co.jp

大江戸Ruby会議 10にima1zumiとfugakkbnが登壇します!

2023年10月15日(日)に開催される大江戸Ruby会議 10に永和システムマネジメントからima1zumifugakkbnが登壇します。

regional.rubykaigi.org

弊社メンバーのタイムテーブルは以下になります。

  • fugakkbn: 10時10分からのNinja Talks 1
  • ima1zumi: 14時30分からのIRBコミッターズの招待公演

大江戸Ruby会議 10は現在参加者募集中です。 弊社から一般参加するメンバーもいるため一緒に楽しみましょう!

asakusarb.doorkeeper.jp


永和システムマネジメントでは全国からリモートワークで働く仲間を絶賛募集しています。

agile.esm.co.jp

Apolloクライアントで同じクエリが複数回呼ばれる問題について

宮崎からお送りします。yoshinoです。

私が参加するプロジェクトでは、フロントエンドにNext.jsを採用していて、バックエンドサーバへのリクエストの際には、ApolloクライアントとGraphQLを利用しています。 先日、同じページから、同一のクエリを複数回リクエストする問題が発生していて、修正する機会がありましたので、その時のことを書こうと思います。

問題

QUERY_Aです。

const QUERY_A = graphql(`
    query query_a {
        user {
            profile {
               name
               email
            } 
            address {
              id
              prefecture
            }  
        }
   }
`)

特別なことをするわけでもなく、普通にデータをフェッチして使います。

const { data, loading, error } = useQuery(QUERY_A)

Chromeのデベロッパーツールを利用してNetworkを見ると、QUERY_Aよりも前にQUERY_Bが呼ばれていることもわかりました。

const QUERY_B = graphql(`
    query query_b {
        user {
            id
            isAuthenticated
        }
    }
`)

QUERY_B -> QUERY_A -> QUERY_B -> QUERY_A -> QUERY_B のように、QUERY_Bが3回、QUERY_Aが2回呼ばれていました。

解決方法

今回のケースでは、idがない箇所にidを足すことで、複数回クエリする問題は解決しました。

const QUERY_A = graphql(`
    user {
+       id
        profile {
+          id
           name
           email
        } 
        address {
          id
          prefecture
        }  
    }
`)

考えられる理由

useQueryのfetch-policyのデフォルト値はcache-firstになります。 cache-firstを指定することで、クエリを投げた時に、キャッシュ上にデータがあるかを確認し、データがある場合は実際にリクエストすることなく、レスポンスを返します。

今回のケースでも、fetch-policyは明示していないので、cache-firstです(QUERY_A, QUERY_Bどちらも)。

Apolloクライアントでは、キャッシュに保存されているデータを問い合わせるたびに、各クエリのオブジェクトにユニークな識別子をつけるための「正規化」と呼ばれるプロセスを経ます。

通常、オブジェクトの __typenameフィールドにidフィールドを追加することで、各オブジェクトに、ユニークなキャッシュ識別子を割り当てを行います(例えば、userだと__typenameUserで取得したidが1であれば、識別子はUser:1となる)。そして、この識別子を見て「キャッシュ上にデータがあるかを確認し、データがある場合は実際にリクエストする」かを決定します。

Chromeの拡張機能であるApollo Client Devtoolsを利用することで、正規化後の__typenameとidに実際に何が格納されているかを確認することができます。

QUERY_Aでidを指定する前は、idを指定しているはずの、QUERY_BのUserオブジェクトのidも、cacheで空の状態であることが確認できました。 QUERY_Bでidを指定するようにすると、どちらのUserオブジェクトのidにも取得してきたidが入るようになります。

このような結果から、Apolloクライアントは、QUERY_Aではidを指定しなかったことにより、正規化プロセスが上手くいかず、複数回のリクエストをしてしまったと考えられます(どうして、QUERY_Bが3回、QUERY_Aが2回呼ばれるのかは、わかっていませんが....)。

終わりに

Apolloクライアントと正規化の時の識別子から生じる問題についての紹介でした。 それでは、良きApolloクライアント + GraphQL開発を!

Rails + minitest な環境で CircleCI の Rerun failed tests を有効化する手順

ふーが です。こんにちは!
明日はいよいよ大阪 Ruby 会議 03 ですね。

現地参加予定なので現地にいらっしゃる方はぜひお話ししましょう。弊社からも多くのメンバーが参加予定ですし、セッションの内容も興味をひかれるものばかりで今からとても楽しみです。

blog.agile.esm.co.jp

さて今回は、2 ヶ月ほど前にリリースされた CircliCI の新機能、Rerun failed tests を有効化してみたのでご紹介します。

Rerun failed tests とは

CI でのテストが失敗した際に、失敗したテストのみを再実行する機能です。失敗したテストのためにテスト全体を再実行する必要がなくなり効率がよくなります。また、これによって再実行時に消費するクレジットも少なく済むため、金銭的なコスト削減にもつながります。

詳しくは以下をご覧ください。

circleci.com

ここからは Rails + minitest な環境での有効化手順の一例として参考にしていただければと思います。

環境

  • Ruby 3.2.2
  • Ruby on Rails 6.1.6.1
  • minitest 5.16.2

有効化前後の比較

まずは Rerun failed tests の有効化前後で UI がどのように変わるかを見てみます。

失敗したワークフローの詳細画面に行くと、右上に "Rerun" ボタンがあります。こちらのドロップダウンメニューを開くと "Setup rerun failed tests" というメニューが出ているのがわかります。これは Rerun failed tests が有効化されていない状態です。

有効化が完了すると、メニュー名が以下のように "Rerun failed tests" に変化し、失敗したテストのみを実行する機能が利用できるようになります。

今回はこの状態、つまり Rerun failed tests を実行できる状態にするのがゴールです。

有効化手順

前提

今回 Rerun failed tests を有効化したプロジェクトでは System test 用のワークフローとそれ以外のテスト用のワークフローの 2 つにわかれていました。そのためそれぞれに対して Rerun failed tests を有効化していきます。

System test

まず System test 用のワークフローから見ていきます。有効化前のテスト実行部分は以下のように記述されていました。

# .circleci/config.yml
system_test:
  docker:
    (snip)

  steps:
    (snip)

    - run:
        name: Run system test
        command: bin/rails test $(circleci tests glob "test/system/**/*_test.rb" | circleci tests split --split-by=timings --time-default=10s)

    - store_test_results:
        path: test/reports

bin/rails test の引数として、CircleCI の glob コマンドで test/system 配下のテストファイル(= System test ファイル)を全て取得した上で、CircleCI のパラレル実行用コマンドに渡して実行している形です。こちらを Rerun failed tests が使えるようにするためには、circleci tests run コマンドを使用する形に変更する必要があります。
https://circleci.com/docs/rerun-failed-tests/#example-config-file-after

今回は以下のように変更しました。

# .circleci/config.yml
system_test:
  docker:
    (snip)

  steps:
    (snip)

    - run:
        name: Run system test
-         command: bin/rails test $(circleci tests glob "test/system/**/*_test.rb" | circleci tests split --split-by=timings --time-default=10s)
+         command: circleci tests glob "test/system/**/*_test.rb" | \
+           circleci tests run --command="xargs bin/rails test" | \
+           --verbose --split-by=timings --timings-type=file --time-default=10s

    - store_test_results:
        path: test/reports

変更した行の 1 行目では変更前と同様の手法で System test ファイルを全て取得しています。大事なのは 2 行目で、circleci tests run コマンドの --command オプションにテスト実行コマンドである bin/rails test を渡しています。このとき xargs コマンドを使用することで前段で取得した System test ファイルが渡されるので、結果として System test のみが実行される形になります。
3 行目は出力やパラレル実行に関するオプションで Rerun failed tests 特有のものではないため説明は割愛します。

System test 以外

続いて System test 以外のケースを見てみましょう。変更前の設定ファイルは以下のように記述されていました。

# .circleci/config.yml
system_test:
  docker:
    (snip)

  steps:
    (snip)

    - run:
      command: bin/rails test

とてもシンプルですね。こちらを Rerun failed tests を使用するためには以下のようにすればよさそうです。

# .circleci/config.yml
system_test:
  docker:
    (snip)

  steps:
    (snip)

    - run:
-       command: bin/rails test
+       command: circleci tests run --command="bin/rails test"

残念ながらこの設定ですと以下のような警告が出力されてテスト自体が実行されなくなってしまいます。

WARN[2023-09-05T01:22:03Z] No test names found in input source. If you were expecting test names, please check your input source.

この警告からわかるとおり、circleci tests run コマンドを使用する場合は必ずファイル名( またはテストケース名 )を渡してやる必要があります。これを踏まえて設定は以下のようになりました。

# .circleci/config.yml
system_test:
  docker:
    (snip)

  steps:
    (snip)

    - run:
-       command: bin/rails test
+       command: |
+         circleci tests glob "test/**/*_test.rb" | \
+           grep -v "test/system/*" | \
+           circleci tests run --command="xargs bin/rails test"

+   - store_test_results:
+       path: test/reports

いったん System test を含めたすべてのテストファイルを取得した上で、grep -v で System test ファイルを除外したものを circleci tests run に渡す形を取っています。
また、Rerun failed tests はアップロードされたテスト結果の XML ファイルをもとに再実行すべきテストを抽出しているようです。そのため、store_test_results の指定も必ず必要になります。

以上でそれぞれ Rerun failed tests が利用できるようになっているはずです。実際に利用できるかを確かめてみましょう。

動作確認

Rerun failed tests が実行できるかどうか、また、失敗したテストのみが実行されているかを確認していきます。確認のため、わざと落ちるテストケースをいくつか用意した上で CI を動かしてみました。

Rerun failed tests を有効化する設定を取り込んだ Pull Request を作成し CI を動かした結果、想定通りテストが失敗しました。

Failure:
TopTest#test_visiting_as_guest [/home/circleci/project/test/system/top_test.rb:13]:
(snip...)

40 runs, 158 assertions, 1 failures, 0 errors, 0 skips

このワークフローの詳細画面を開くと、無事にドロップダウンメニューに Rerun failed tests が表示されるようになりました🎉

こちらをクリックして実行してみます。失敗したテストのみが実行されるようになっているでしょうか。

Failure:
TopTest#test_visiting_as_guest [/home/circleci/project/test/system/top_test.rb:13]:
(snip...)

4 runs, 7 assertions, 1 failures, 0 errors, 0 skips

実行されるテスト数は減っているようですが、 1 run を期待しているところ 4 runs となっています。失敗しているテストが定義されているファイルでは 4 つのテストケースがあるのでファイルごと実行されているように見えます。
こちらに関しては、ドキュメントの FAQs に答えがありました。

Question: Will this functionality rerun individual tests?
Answer: No, it will rerun failed test classnames or test filenames (file) that had at least one individual test failure.
https://circleci.com/docs/rerun-failed-tests/#FAQs

残念ながら "失敗したテストケースのみ" の実行には対応していない、というのが現状のようです。 ただ、store_test_results の設定により保存される XML ファイルには失敗したテストケース名があるため、これをうまく扱えば実現は可能かもしれません。

とはいえ当初の目的通り Rerun failed tests を有効化し、失敗したテスト(が定義されているファイル)のみを実行する、という目的は達成できました。

Rerun failed tests を実行したワークフローを確認する方法

ワークフローを一覧したときに、どれが全実行したものでどれが Rerun failed tests で実行したものかが確認できると良いですよね。その点は一目でわかるようになっています。
ダッシュボードからプロジェクトを選択すると実行されたワークフローの一覧が表示されますが、Rerun failed tests で再実行したものについては FAILED TESTS RERUN というラベルがつきます。

おわりに

CI 実行の待ち時間は短ければ短いほどいいですよね。
そんな全人類の切なる願いにこの記事が少しでもお役に立てれば幸いです。


永和システムマネジメントでは全国からリモートワークで働く仲間を絶賛募集しています。

agile.esm.co.jp