プラグインから始めるOSS開発

この記事は 東葛.dev Advent Calendar 2025 11日目の記事です。

昨日はponyoxaさんで「東葛.devのここが好き 3選」でした。

adventar.org

はじめに

こんにちは kozy4324 です。さっそくですが質問です。

みなさんOSS開発してますか?!

こう書くと「ソフトウェアエンジニア、余暇の時間もOSS活動に従事すべき!(MUST, SHOULD)」みたいに聞こえてしまいそうですが、本記事はそういう主張をするつもりはありません。どちらかというと「これからOSS開発チャレンジしたいけど何をすればいいか分からない」という人向けに、OSS開発を始める一つの「型」としての「プラグイン開発」を提案したいと思います。

筆者のOSS開発スタンス

自分に関して言うと、フルコミットOSS開発をしていたり、有名ライブラリのメンテナをしているというレベルでは全くありません。

あくまで趣味の範囲で余暇に以下のようなことに取り組んでいるという感じです。

  • 他の人も使いそうでライブラリとして切り出せそうな処理や関数があればOSSライブラリとして公開してみる
  • 仕事で使っていたり気になるライブラリを見つければソースコードを読んだり、Issueを眺めたり、PRを送ったりする

OSS開発を始めたいけど敷居が高そうで何やればいいか分からん問題

そう感じている人は少なからずいるのではないのでしょうか?私も昔はそうでしたし、今もそうかもしれません(有名&長年続いている&規模の大きなOSSにIssueを登録するのはいつだって緊張します)。

さて、OSSコントリビューション最初の1歩としてよくオススメされる「型」として「READMEやドキュメンテーション、コメントなどの記述ミスやtypoを見つけたら修正PRを送ってみましょう」というものがあると思います。OSSプロジェクトにおいてドキュメンテーションは大事ですし、そこまで手が回っていないプロジェクトも多くあるのが現状です。こういった貢献から入っていくのはすごく価値がありますし、何より修正の難易度も高くない、かつ自然言語で書かれた文章からスタートできることもポイントでしょう。

だがしかし、やはりコードが書きたい!コードを書いて貢献したい!!と思うのがエンジニアの心情ではないでしょうか。少なからず私はそうです。その場合にドキュメンテーションの次のステップとして何か「型」があるといいなと考えました。そこで「プラグイン開発」です。

プラグインプラグイン志向なOSSプロダクト

AIによる概要

プラグインとは、既存のソフトウェア(アプリやWebブラウザなど)に後から機能を追加・拡張するための小さなプログラムのことで、「拡張機能」や「アドオン」とも呼ばれ、本体にはない便利な機能(PDF表示、フォーム設置、デザイン変更など)を、個別にインストールして使えるようにするものです。

AIくんの回答をそのまま引用させてもらいました。

さて、世の中にはプラグイン機構、もしくは明確にプラグインとは言っていないがほぼそういった思想をもって設計されているOSSプロダクトが数多く存在しています。また、ここが割と重要なポイントでもあるのですが、筋の良いプラグイン機構を持つOSSプロダクトでは プラグイン開発者が集まる → ユースケースが広がる → エンドユーザーの裾野が広がる → 大きなエコシステムを形成する という機序が発生するようにも感じます。

プラグイン開発から始めることのメリット

なぜプラグイン開発がオススメなのかというポイントをいくつか挙げてみます。

1つの課題に集中することができる

大きな課題領域やユースケースは本体のOSSプロダクトが担ってくれており、それを利用する中で「追加でこういったこともできればいいのにな」と思うシーンがプラグイン開発のスタート地点になるでしょう。「本体に足りない+α」の課題部分だけに集中すれば良いのは開発を始めやすいポイントになるのではないでしょうか。

また1つの課題にのみ集中すれば良いということは実装を小さくシンプルにできるということです。その結果として素早くリリースすることも可能になるでしょう。これも開発を始めやすいポイントになると思います。

エンドユーザーに届けやすい&使ってもらいやすい

すでにエコシステムとして発達しているOSSプロダクトであるということはそのエンドユーザーがすでに多く存在します。エンドユーザーにとっては全く別の新しくツールを導入するよりもプラグインを導入する方が導入コストは低くなります。つまり自分が開発したプラグインを利用してもらえるチャンスも高いと言えます。

またあるOSSプロダクトのプラグインであれば特定の命名規則に沿って公開されることが慣例的になっているケースをよく見かけます。例えば自分は Ruby をよく使うのですが、rubygems.orgにおいて rubocop- という接頭辞や rspec- という接頭辞を検索することでそのプラグインgemを検索することが容易になっています。

本体OSSプロダクトにも貢献するチャンスができる

プラグイン機構を設計するということは、そのOSSプロダクトのユースケースにおいてプラグインにはどこまで何ができるのかを考えることだと思います。またプラグイン開発の開発体験はプラグインに対してどういったAPIが公開されているのかということにも直結します。つまりプラグイン開発者もそのOSSプロダクトのユーザー形態の1種と考えることができます。

より良いプラグイン機構とプラグインユースケースを実現するためにはプラグイン開発者からのフィードバックも必須だと考えられます。プラグイン機構を持つOSSプロダクトにおいてはプラグイン開発者も重要なロールの一部なのではないでしょうか。

kozy4324のOSSプラグイン開発をふりかえる

プラグイン機構を持つOSSプロダクト事例の紹介を兼ねて、実際に自身が取り組んできたOSSプラグイン開発をふりかえってみます。

Gruntとgrunt-concat-sourcemap

gruntjs.com

github.com

GruntはJavaScript製のTask Runnerですね(なつかしい)

WebpackもTypeScriptもまだまだ無かった頃の話です。複数のJavaScriptを連結(concat)して1ファイルとして配信していた時に、今や当たり前にあるSourceMapも後発として生まれました。単純にファイルを連結する機能はGruntに最初からありましたがSourceMapは解決してくれません。無いなら作るかとなったのがこのgrunt-concat-sourcemapです。

initial commitを確認すると今からもう12年以上も前です。コード量は100行ぐらいのJavaScriptファイルが1つのみです。コンパクトですね。

https://github.com/kozy4324/grunt-concat-sourcemap/blob/master/tasks/concat_sourcemap.js

その時のニーズにマッチしたのか50スターほど付けてもらいました。なお余談ですがSourceMap解決ロジックがバグっており少し複雑なケースでSourceMapがぶっ壊れます。

1つの課題に集中できたので素早くリリースできたプラグインだったなぁと思いつつ、concat時のSourceMapは本体機能でちゃんと解決されるようになったのでお役御免ということでarchiveとなりました。

JasmineとJasmine-TAPReporter

jasmine.github.io

github.com

JavaScriptスティングラインブラリであるJasmineのReporterプラグインを作っていました。これのinitial commitはさらに古く14年も前だった。実装を見たら「おお!CoffeeScript CoffeeScriptじゃないか!久しぶりじゃないか 元気にしてたか」という気持ちになりました。これも100行程度のスクリプトファイルが1つでコードサイズもコンパクトなものになっています。

https://github.com/kozy4324/Jasmine-TAPReporter/blob/master/src/tapreporter.coffee

RuboCopとrubocop-oneoff_codemod

github.com

github.com

RuboCopのプラグインを作りたくて作ってみました。TSKaigi 2025の基調講演でAnthony FuさんがESLint Plugin Commandというやつを紹介していたのですが、

ESLint Plugin Command

「カッコよ!それRubyでもできんかな?」と思ってRuboCopのプラグインとして作ってみたというやつです。PoCレベルの実装だけして「似たようなことできるやん!」で満足して放置してしまっていますね(良くない)。アイデア思いついてからgemとして公開するまで1日ぐらいしか経っていません。そういったスピード感でリリースまでいけるのはプラグイン開発のオススメしたい点だと思います。

これも一つ一つのcop(RuboCopのpluginの単位となるもの)はコンパクトになっているのでソースコードリンクを掲載しておきます。

https://github.com/kozy4324/rubocop-oneoff_codemod/blob/b7e0d49315dea61cdf7fb38d50846c01402259b8/lib/rubocop/oneoff_codemod/cops/keep_unique.rb

RubyLSPとruby-lsp-rake

shopify.github.io

github.com

Shopifyが開発しているRubyLSPにもプラグイン機構があります(ドキュメント上は Add-on とありますが、この記事中は全て「プラグイン」という概念でまとめさせてください)

Add-ons | Ruby LSP

ドキュメントページがしっかりと整備されておりRubyLSPの仕組みを理解したいなと思ったのでプラグインを作ってみたという流れのものです。

本体のRubyLSPに比べると小さい実装にまとまっており、これぐらいなら自分でも作れそうと思ってもらえれば良いなという事例として紹介してみました。

https://github.com/kozy4324/ruby-lsp-rake/tree/f76f0467c1379d584072d6b509351270cd49c055/lib/ruby_lsp/ruby_lsp_rake

ruby-lsp-brakemanへのPR

RailsのセキュリティスキャンツールにBrakemanというものがあります。

Brakeman: Brakeman Security Scanner

このBrakemanのRubyLSP Add-onが作られていました。その名もruby-lsp-brakeman

GitHub - presidentbeef/ruby-lsp-brakeman: Ruby LSP Addon for Brakeman

このBrakemanのAdd-onですがソースコードのエラー箇所をLSPとして表示してくれます。ですがソース位置を表す情報として行情報しかなくカラム位置情報がありません。なんとかうまいことカラム位置を含められないものかと思い修正を検討しようと思いましたが、修正するにしてもまずはテストないと修正はキツいだろうなということでテストを追加するPRを送ってみたというのがこちらです。

github.com

ruby-lsp-rakeを自作して得た知見を展開してみるムーブをかましてみました。なお肝心のカラム位置情報の追加ですが、Brakemanが扱うASTにそもそも具象情報としてのカラム位置が欠落しており、Add-onじゃなくてBrakeman本体をなんとかしないと無理では...となって頓挫しています。現実はなかなか厳しい。

自分でプラグインを開発するだけでなく、他のOSSプラグインに対してコラボレーションするというOSS開発スタイルもあるよねということで事例紹介してみました。

まとめ

プラグイン開発のオススメポイントを説明させてもらいました。またその具体例として自分が過去に行ってきたOSSプラグイン開発の事例を紹介してみました。紹介した事例の本体OSSプロダクトは有名どころだったり長年続いていて規模の大きなコードベースのものばかりですが、そのプラグイン開発であれば本体に比べて敷居が低く見えたのではないでしょうか?またプラグイン機構(もしくは似たような設計)を持つOSSプロダクトの一部を紹介させてもらいましたが、そういったOSSプロダクトが世の中に多くあることも多少は伝わったのではないでしょうか。

少しでも「このやり方だったら自分でもOSS開発にチャレンジできそう」と思ってもらえたなら幸いです。この記事を最後までお読みいただきありがとうございました。

さいごに: 東葛.devと私

東葛.devのAdvent Calendarなのでさいごに東葛.devの紹介をしておきます。東葛.devは東葛地区中心のIT/Web技術コミュニティで定期的なオフラインイベントやDiscordなどでワイワイやっています。

toukatsu.dev

私個人としての参加モチベーションはこの記事で紹介させてもらったようなOSS開発にまつわる活動や事柄を会話したり相談したりしたいなぁというものがあります。この記事を読んで同様な興味・関心を持ってもらえたならば東葛.devの方もぜひのぞいてみてください。よろしくお願いします!

OSS活動記 #1 - herb - javascript_tag内のerb出力にdebug spanが付与されるのをなんとかしたい

対象リポジトリ

github.com

遭遇した事象

herbにはdebug modeがある。これをonにすることでerb出力が可視化されて便利。

debug modeがonになっている様子

とても便利なのだがどうやらjavascript_tagヘルパー内のerb出力にも作用して余計なspanタグを付与してしまうみたい。

<%= javascript_tag do %>
  alert('<%= 1 %>');
<% end %>

実装を確認したところ、通常のscriptタグのケアはなされていた。以下は問題ない。

<script>
  alert('<%= 1 %>');
</script>

scriptタグを使うべきか?でいうと、例えばCSPを有効にしてscriptタグにnonceを付与したい場合はjavascript_tagが使えないと問題である。

ActionView::Helpers::JavaScriptHelper

scriptタグがケアされているのだからjavascript_tagヘルパーも同様にケアされるべきだろうと判断した。

Issueを上げる

というわけでIssueはこんな感じ。基本的に英作文はLLMに手伝ってもらった。

github.com

ソースを読んで解決策を考えてみる

このdebug spanを出力するのは Herb::Engine::DebugVisitor クラスがやっている。

herb/lib/herb/engine/debug_visitor.rb at a43c7e6f138cdaa759468f11427b98ecd5e131f6 · marcoroth/herb · GitHub

scriptタグをどうやって識別しているのかを見る。

  1. スタック @element_stack = [] を持つ
  2. HTML要素ごとに処理される visit_html_element_node メソッド内でスタックを積む
  3. erb出力ごとに処理される visit_erb_content_node メソッド内で in_excluded_context? でチェック
  4. in_excluded_context? で現在のスタックを見てscriptタグが含まれているかを確認する

ベースはvisitorパターンで辿ったHTML要素をスタックに積んで、それをコンテキストとして利用していると理解した。

さて、HTML要素はスタックに積むが javascript_tag do といったERBBlockに対してはスタック操作はなされていない。同様の操作を行えば識別可能になるのではないか。

PRを作る

土日に入ったタイミングでIssueを上げたためかレスポンスはまだ来ていなかった。解決策のPoCにしてもコードを見せるのが手っ取り早いよなってことでPRを作成した。もちろん全ての英作文はLLMとやっている。

github.com

ソースを読んで分かったこと

herbはhtml/erbのパーサーであるのだけど、その抽象構文木のノードに関わる実装はtemplates以下にあるテンプレートから動的に生成されていた。

これRubyパーサーのPrismで見たアプローチだなってなった。

あとherb ASTからerb出力するためのRubyコード生成は Herb::Engine , Herb::Engine::Compiler あたりが主にその責務を持つ。

visitorを複数注入できるのでdebug modeであれば Herb::Engine::DebugVisitor が注入されるし、任意のvisitorを実行時に追加可能な設計にもなっていた。

おわりに

まだPRを作っただけなので動きがあったらまたブログ記事に書き残すか追記をしようかと思っています。個人的に障壁が高かったOSS活動に伴う英作文や、あとソースコードリーディングに関してもLLMの力を借りることで難なくできるようになっていい時代になったものだなぁという感想でした。

追記(2025-12-07 22:36)

PRは少し手直しされてマージされていた🙌

2025年11月のふりかえり

12月になりましたね。12月が終わるとどうなる?知らんのか。2026年が...。

今年のふりかえりもあと2回ですね。11月をふりかえります。

前回はこれ:

kozy4324.hatenablog.jp

OSS活動

herbとかreactionviewを触って見つけたバグを報告していた。

Parser error in v0.8.0 when using `if` inside `else` of a `case` block · Issue #860 · marcoroth/herb · GitHub

reactionviewで属性値にJSON埋め込むと2重にエスケープ処理が走ってJSONが壊れるというIssueがあって、そのPRがDraftのまま進捗ないのでコメントしてみたりしていた。

Fix double escaping in attributes, `<script>` and `<style>` tags by marcoroth · Pull Request #53 · marcoroth/reactionview · GitHub

herbを使いたいのだけどこのバグが直らないことにはプロダクトで使えんな〜ってなってて、見方によってはコントリビューションチャンスとも言えるわけで何かできること考えてみたい。

英語学習

なんとかスピークだけは続けている。TOEIC学習の進捗は、ありません...。そっちはいい加減やっていこう。

読書

「入門 データ構造とアルゴリズム」をゆるゆると読んでいる。

https://www.amazon.co.jp/dp/4873116341

書いてあること+周辺トピックを掘り下げてブログ記事とか書いてアウトプットしたいなぁとなっている。

ランニング

頻度が落ちてしまった。子が通う小学校でインフルエンザによる学級閉鎖が発生したりと緊張感のある週もあってなかなか厳しい月になってしまった。幸い体調面の調子は悪くないので免疫落とさない程度に追い込んでいきたい。

体重

ギリ保っている。ランニングの代わりに筋トレを意識的にやっていたりもする。

ブログ

いちおう2記事書いた。

2本目の記事を書こうと思ってしばらくずっと浮動小数点関連の論文を読んでいたら気づいたら月末になっていたという展開。間隔が空いてしまったのはちょっと反省。

勉強会

念願の東葛.devに初参加してきた。もちろん柏.rbも継続している。

東葛.devで発表したLTはこちら。好きなことを好きなように喋らせてもらった。

speakerdeck.com

さて、12月と1月は子の中学受験があるため勉強会関連の参加は控えることになっている。2ヶ月充電期間だけど、LTでもなんでも発表できる程度にネタを仕込んでおこうかと思っています。

KPT

  • Keep
    • OSS活動
    • 読書
    • 体重
    • 勉強会
  • Problem
    • 英語学習
    • ランニング
    • ブログ
  • Try
    • 英語学習
    • ランニング
    • ブログ
      • 勉強会とかの外部交流しない代わりにインプット&アウトプットやっていき

Rubyと深掘る浮動小数点数

はじめに

コンピューターサイエンスの基礎的なことをゆるく独学していきたいなぁということで「CSゆる独学」というカテゴリーで記事を書いていこうかとなりました。なおこのテーマを思い立って筆を取るまでにはや数週間経過しているぐらいのゆるさです。やっていきます。

最初のテーマは浮動小数点数、なぜ浮動小数点数

趣味でRailsアプリケーションの bundle outdated を毎日叩いて依存gemのchangelogを眺めていました。とある日のjson gemのchangelogで見つけたものがこれ。

令和のこの時代に浮動小数点数に関わる部分で7倍も高速化する余地があるんだ!ということに驚きました。と同時に「浮動小数点数のこと、なんとなくしか把握できてないな」となったのでこのタイミングで改めて浮動小数点数を学び直します。

おさらい、浮動小数点数

まずはWikipediaを確認です。

浮動小数点数 - Wikipedia

浮動小数点数(ふどうしょうすうてんすう、英: floating-point number)は、実数をコンピュータで処理(演算や記憶、通信)するために有限桁の小数で近似値として扱う方式であり[1]、人間が多く用いる10進数での数値の計算や表記に加えて、コンピュータの数値表現で多く用いる2進数の数値でも採用されている。多くの場合、符号部、固定長の指数部、固定長の仮数部、の3つの部分を組み合わせて、数値を表現する。

とりわけ、多くのシステムで採用されている IEEE 754 形式 を確認しましょう。ポイントとしては以下らへんで、

  • 符号部は、 0 を正、1 を負とする
  • 仮数部は、整数部分が 1 であるような2進小数の小数部分(ケチ表現)を表す
  • 指数部は、符号なし2進整数とし、半精度では 15、単精度では 127、倍精度では 1023、四倍精度では 16383 のゲタを履かせたゲタ履き表現で表す

単精度の場合で言うと、仮数部は23bitsありますが整数部分の 1. が隠れているので2進数の有効桁数は 23bits + 1bits = 24bits というケチ表現の部分でしょうか。あと指数部もゲタ履き表現というものがあります。これらを押さえておかないと後に出てくる実装で「あれ、この演算は何?」となるので注意しましょう。

さて、このCSゆる独学ではプログラミング部分はRubyを使っていこうかと考えています。もちろんRubyのFloatクラスも IEEE 754 形式 ということですね。

class Float (Ruby 3.4 リファレンスマニュアル)

10進数値と IEEE 754 形式 2進数表現を相互変換してくれるページもありました。こちらを触ってみるとより内部表現のイメージを掴みやすいかと思います。

Float Exposed

RubyでFloatを2進数表現に変換、またはその逆の操作

Array#packString#unpack1 を利用することで変換が可能です。

g で単精度、 G で倍精度です。

$ ruby -e'puts [0.75].pack("g").unpack1("B*")'
00111111010000000000000000000000

$ ruby -e'puts ["00111111010000000000000000000000"].pack("B*").unpack("g")'
0.75

$ ruby -e'puts [0.75].pack("G").unpack1("B*")'
0011111111101000000000000000000000000000000000000000000000000000

$ ruby -e'puts ["0011111111101000000000000000000000000000000000000000000000000000"].pack("B*").unpack("G")'
0.75

お手軽ですね。

10進数値から2進数表現を得るアルゴリズム

一般教養レベルの話ですね。なのでLLMに質問丸投げした回答を載せます。

  • 10進整数 → 2進数(整数部)の変換:2で割って余りを集める
    1. n を 2 で割る
    2. 商 ← 次の n として使う
    3. 余り(0/1)を記録する
    4. 商が 0 になるまで繰り返す
    5. 最後に余りを逆順に並べる
  • 10進小数 → 2進数(小数部)の変換:2を掛けて、出た “1 or 0” を集める
    1. x に 2 を掛ける
    2. 結果の整数部(0/1)をビットとして記録
    3. 小数部だけを次の x にする
    4. 必要な桁数に達するまで繰り返す

疑問:プログラムはどうやって10進数字文字列から浮動小数点数を得ているのか?

ここで1つ疑問が生じました。例えばソースコード上にある浮動小数点数リテラル(10進数字文字列)はどうやって計算機で処理可能な値(2進数表現である浮動小数点数)に変換されているのか?ということです。

イメージ
"0.75" → 00111111010000000000000000000000 → Float(0.75)

前述のアルゴリズムがそうとした場合、少数部の変換で浮動小数点数による演算が登場しています。つまり浮動小数点数を得るためには浮動小数点数が必要となり、ニワトリが先かタマゴが先か状態になっていそうです。

単純に少数部を整数として扱い2倍して繰り上がりしたら 1、繰り上がりしなかったら 0、みたいなことでもできそうだけど計算効率がとても悪くなりそうな予感がします。

浮動小数点数に関する論文をあたる

自分一人で考えるには行き詰まったところで、きっとこういった部分で学術的な研究の積み重ねがあるやろ!ということでLLMに質問してみました。

decimal文字列 → binary浮動小数点、またはその逆に変換するアルゴリズムに関して、時系列順でどういった論文がある?で出してもらったものが以下の通り。

  • How to Read Floating-Point Numbers Accurately (1990) — William D. Clinger
  • Correctly Rounded Binary-Decimal and Decimal-Binary Conversions (1990) — David M. Gay
  • Printing Floating-Point Numbers Quickly and Accurately (1996) — Robert G. Burger & R. Kent Dybvig
  • Printing Floating-Point Numbers Quickly and Accurately with Integers (2010) — Florian Loitsch
  • Ryū: Fast Float-to-String Conversion (2018) — Ulf Adams
  • Grisu‑Exact: A Fast and Exact Floating-Point Printing Algorithm (2020) — Junekey Jeon

json gemが高速化したところで言及されていた ryu algorithm は2018年の論文に関するものらしいですね。

How to Read Floating-Point Numbers Accurately にある変換アルゴリズムを実装する

1点目のWilliam D. Clingerによる論文を試しに読んでみます(もちろんLLMに翻訳させながら)

https://dl.acm.org/doi/pdf/10.1145/93542.93557

書いてある内容はLLMの翻訳によってなんとなく読めますが、サンプルコードがSchemeによるもので読み解きが非常に困難です。LLMに解説をさせていたところ、論文に言及のあるアルゴリズムと同等なものを解説している記事を見つけました。

https://www.exploringbinary.com/correct-decimal-to-floating-point-using-big-integers/

こちらの解説の方が理解できます。ポイントとしては以下というところでしょうか。

  • 浮動小数点数の有効桁数に収まるよう任意精度の整数を使って正しく丸められる浮動小数点値を得ること
  • そのために10進数字文字列 → 有理数表現に変換してから 252 ≤ q < 253 に収まる範囲にスケーリングしている
  • 整数演算のみで完結

これをベースにコードを書いてみました。もちろんRubyで。

gist.github.com

実行結果は以下の通り、うまく変換できていますね。

1. Express as an integer times a power of ten
3.14159 = 314159 * 10^-5

2. Express as a fraction
314159 / 100000

3. Scale into the range [2^52,2^53)
314159 * 2^51 / 100000 = 707423177667543826432 / 100000

4. Divide and round
707423177667543826432 / 100000 = 7074231776675438 remainder 26432
quotient rounds down to 7074231776675438

5. Express in normalized binary scientific notation
7074231776675438 converts to 11001001000011111100111110000000110111000011001101110
Unnormalized binary scientific notation  : 11001001000011111100111110000000110111000011001101110 * 2^-51
The unscaled and normalized value becomes: 1.1001001000011111100111110000000110111000011001101110 * 2^-51 * 2^52
Normalized binary scientific notation    : 1.1001001000011111100111110000000110111000011001101110 * 2^1

6. Encode as a double
The sign field    : 0
The exponent field: 1024 (1 + 1023)
The fraction field: 2570632149304942 (7074231776675438 - 2^52)

input : 3.14159 => 0100000000001001001000011111100111110000000110111000011001101110
result:            0100000000001001001000011111100111110000000110111000011001101110 => 3.14159
1. Express as an integer times a power of ten
1.2345678901234567e22 = 12345678901234567000000 * 10^6

2. Express as a fraction
12345678901234567000000 / 1

3. Scale into the range [2^52,2^53)
12345678901234567000000 / 2^21 = 12345678901234567000000 / 2097152

4. Divide and round
12345678901234567000000 / 2097152 = 5886878443352969 remainder 1355712
quotient rounds up to 5886878443352970

5. Express in normalized binary scientific notation
5886878443352970 converts to 10100111010100001010110110010011100111011001110001010
Unnormalized binary scientific notation  : 10100111010100001010110110010011100111011001110001010 * 2^21
The unscaled and normalized value becomes: 1.0100111010100001010110110010011100111011001110001010 * 2^21 * 2^52
Normalized binary scientific notation    : 1.0100111010100001010110110010011100111011001110001010 * 2^73

6. Encode as a double
The sign field    : 0
The exponent field: 1096 (73 + 1023)
The fraction field: 1383278815982474 (5886878443352970 - 2^52)

input : 1.2345678901234567e22 => 0100010010000100111010100001010110110010011100111011001110001010
result:                          0100010010000100111010100001010110110010011100111011001110001010 => 1.2345678901234568e+22

まとめ

浮動小数点数の変換(decimal文字列⇔binary浮動小数点)に関する論文の1つを読み、そこで説明されているアルゴリズムRubyで実装してみることで変換処理の理解を深めることができました。ただしこのWilliam D. Clingerによる論文は後続の論文に対する基盤となる考え方を提供しているに過ぎないらしいです。この論文の後によりいくつかのアルゴリズムが発表され高速化がなされているんだと理解しました。

最初に言及されたryu algorithmについてや、そもそも現在のここらへんのRuby実装がどうなっているのかという別の興味関心も湧いてきました。別の論文もゆるく読んで学んでいきたいなと思います。また次回!

素朴な再帰下降パーサをRubyで書いてみる その2

改めて書いてみて色々気付きがあったなという記事です。

kozy4324.hatenablog.jp

成果物リポジトリ

github.com

Lexerの改善

1トークン分を読み進めてトークンを取得する #advance を実装していましたが、自分で実装した割に呼び出した後にlexerがどういった状態になるかが曖昧になっていました。読み込み位置と、その読み込み位置に対してどのトークンが取得できるのかを改めて整理してみました。それに加えて #token で取得できるトークンに加えて、さらにその次のトークンを取得する #peek も定義しています。

ここら辺をコメントに記載した箇所: https://github.com/kozy4324/simple-json-parser/blob/0ee2c886047f04b2825db9474034fb8f5774d8de/lib/simple/json/lexer.rb#L10-L39

Parserの改善

実装時の注意点を以下のように整理して実装に取り組みました。

  • 基本は1文法ルールに1パースメソッドが対応する
  • 読み込み位置はパースメソッド開始時点でその文法の先頭にあり、終了時点で末尾まで進むものとする
  • トークン列で考えるとメソッド開始時点でその文法の直前トークン、終了時点でその文法の最終トークンが token から取得できる

ここら辺をコメントに記載した箇所: https://github.com/kozy4324/simple-json-parser/blob/0ee2c886047f04b2825db9474034fb8f5774d8de/lib/simple/json/parser.rb#L11-L26

これにより、例えば object の1つの key & value を処理する parse_member は以下のような実装にできました。ホワイトスペースを処理するタイミングも文法通りにしています。

# member ::= ws string ws ':' element
def parse_member
  parse_ws
  key = parse_string
  parse_ws
  @lexer.advance # ':'
  value = parse_element
  { key => value }
end

numberのパース処理

文法上は以下の通りです。

number ::= integer | fraction | exponent
integer ::= digit | onenine digits | '-' digit | '-' onenine digits
digits ::= digit | digit digits
digit ::= '0' | onenine
onenine ::= '1' . '9'
fraction ::= "" | '.' digits
exponent "" | 'E' sign digits | 'e' sign digits
sign ::= "" | '+' | '-'

これは1つのメソッドで対応してみました。 Lexer#number_value がその実態です。

INTEGER_REGEXP  = /-?(?:0(?![0-9])|[1-9][0-9]*)/
FRACTION_REGEXP = /\.[0-9]+/
EXPONENT_REGEXP = /[Ee][+-]?[0-9]+/
NUMBER_REGEXP   = /(#{INTEGER_REGEXP})(#{FRACTION_REGEXP})?(#{EXPONENT_REGEXP})?/

# 現在読み込み位置以降の数値を取得して読み込み位置を進める
def number_value = @scan.scan(NUMBER_REGEXP).then { |s| /[.Ee]/ =~ s ? s.to_f : s.to_i }

stringのパース処理

文法はこうです。

string ::= '"' characters '"'
characters ::= "" | character characters
character ::= '0020' . '10FFFF' - '"' - '\' | '\' escape
escape ::= '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex hex hex hex
hex ::= digit | 'A' . 'F' | 'a' . 'f'

バックスラッシュのエスケープを正規表現で解決するには悩ましい。ということでこちらは1文字ずつ処理する形式で実装してみました。

# 現在読み込み位置以降の文字列値を取得して読み込み位置を進める
def string_value # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
  string = +""
  @scan.getch # "
  until (c = @scan.getch) == '"'
    raise "unexpected end of input." if c.nil?
    raise "invalid ASCII control character in string." if c.ord < 20

    if c == "\\"
      c = @scan.getch # escape
      case c.ord
      when 34, 92, 47 # " \ /
        string << c
      when 98 # b
        string << 8.chr # BS
      when 102 # f
        string << 12.chr # FF
      when 110 # n
        string << 10.chr # LF
      when 114 # r
        string << 13.chr # CR
      when 116 # t
        string << 9.chr # HT
      when 117 # u
        hex4 = @scan.scan(/[0-9a-fA-F]{4}/)
        string << hex4.to_i(16).chr(Encoding::UTF_8)
      else # rubocop:disable Lint/DuplicateBranch
        # JSON仕様上は許可されないescape文字だがJSON.parseでは無視する振る舞いなのでそれに合わせる
        string << c
      end
    else
      string << c
    end
  end
  string
end

結局実装しなかったこと

  • 文法が崩れている時のエラー処理
    • 期待したものと異なるトークンがやってきた時のケアとか
    • object や array の閉じ忘れとか
  • 文法エラー時の適切なエラーメッセージ
    • 〇を期待したけど△がやってきたよとか
    • ソース文字列中のこの場所でエラーだったよとか

ただしパース処理中のここら辺でエラー処理を実装すれば良さそうだなという感触は掴みました。

発展系?

文法的に不完全な状態でも簡単なルールであれば欠落したトークンを保管してあげることなどができそうです。つまりそれがエラートレランスな機能を持つパーサーということか?と少し思いました。

文法ありきな話ではありますが、文法エラー時にどういった戦略をとるか、またはとれるかを考えてみると面白いかもしれません。

とはいえRubyの文法に対してPrismはどんなエラートレランスが実現できているんだろうか、JSONに対して複雑性が全然違いそうなので全く想像できていないなぁともなりました。そういった観点でPrismの実装も眺められるようになるといいかもしれませんね。

まとめ

  • 素朴な再帰下降パーサを実装してみて、それなりに実装できました
  • JSON文法ぐらいの大きさ・複雑さであれば学習用としてちょうど良かった気がします
  • 文法通りに実装するところから発展してエラー処理とそのユーザー体験を突き詰めるとユニークなパーサが実装できるかもしれませんね

2025年10月のふりかえり

今年も残り2ヶ月ですね。10月をふりかえろう。

前回はこれ:

kozy4324.hatenablog.jp

OSS 活動

これといったOSSコントリビューションはなかった。

Ruby処理系を理解したくてRubyのしくみに沿った内容を深掘りしている。その一環でまだ最初の構文解析器に取り組んでいるのだけど、pure Ruby実装のJSONパーサーを書いている。車輪の再発明は楽しい。

github.com

これは俗にいうLLパーサーで再帰下降構文解析という分類になるのだけど、実際に自分の手で書いていると気付きは多い。ここら辺の気付きはまとめてまたブログにでもアウトプットしたい。

英語学習

スピークは毎日続けている。

改めてTOEICちゃんと勉強するかという気持ちになったので公式問題集に取り組んでみることにした。TOEICの試験対策がしたいというよりはTOEICの問題に出てきがちな文法・語彙をちゃんと押さえておきたいなという意図が強い。問題集を1周したらTOEIC申し込んでみるか〜と思いつつ、1日に1〜2問解くか解かないかという驚くほど遅いペースで進んでいるので途中で頓挫するかもしれないなって書いてて思った。ここら辺は来月のふりかえりポイントそのものっぽい。

読書

10月は全然読書しなかった。唯一読了したのは技術系じゃないやつだ。ランニングのお供にゆる言語学ラジオよく聴いてます。

https://www.amazon.co.jp/dp/4103564318

独学でいいのでコンピュータサイエンスの基礎的なことをちゃんと学びたいな思っていて、地元勉強会コミュニティでオススメされて、ずっと気になっていた本をポチった。11月〜12月はこの書籍と向き合いたい。

https://www.amazon.co.jp/dp/4873116341

ランニング

久しぶりに100km未達。月半ばに足を痛めてしまってしばらくランニングを休んでいたのが原因。幸い走れる程度には回復したのでまた11月コツコツと走っていきたい。

体重

しっかりと増加トレンドになってしまった。ランニング休む前から傾向一緒やんけっていうのは内緒だ。ここはしっかりと向き合おう。

ブログ

構文解析勉強シリーズで2本書いていた。ペース的にはこれぐらいが平常運転感かなぁという気持ちになっている。

JSONパーサー書いたやつをアウトプットして、次のスタックマシンの章に進みたいな。

勉強会・カンファレンス

Kashiwa.rb以外だとRailsTokyoに参加した。Rails関連が盛り上がって良い。

Kashiwa.rb以外の勉強会に参加するという意味では達成している。11月は東葛.devに参加するつもりです。

toukatsu.connpass.com

KPT

  • Keep
    • OSS活動
    • 英語学習
    • 読書
    • ブログ
    • 勉強会・カンファレンス
  • Problem
    • ランニング
    • 体重
  • Try
    • 読書
    • ブログ
    • ランニング

素朴な再帰下降パーサをRubyで書いてみる

再帰下降パーサとは?

Wikipediaを引用すると、

再帰下降構文解析 - Wikipedia

再帰下降構文解析(さいきかこうこうぶんかいせき、英語: Recursive Descent Parsing)は、相互再帰型の手続き(あるいは再帰的でない同等の手続き)で構成されるLL法のトップダウン構文解析であり、各プロシージャが文法の各生成規則を実装することが多い。従って、生成されるプログラムの構造はほぼ正確にその文法を反映したものとなる。そのような実装の構文解析器を再帰下降パーサ(Recursive Descent Parser)と呼ぶ。

なるほど分からん。

Rubyにまつわる再帰下降パーサ実装をいくつか眺めてみる

Prism

Ruby のデフォルトになった手書きパーサ。

github.com

lexer の実装も parser の実装も全部 prism.c の中に書かれていそう。

Ruby の文法がシンプルではない方の部類だと認識しており、いきなりハードルが高すぎたようだ。次。

Herb

Kaigi on Rails 2025 で(少なくとも自分の中で)注目だったやつ。

github.com

erb/html をパースして AST を作成しているところが再帰下降パーサの実装になっている。

パース処理は herb_parser_parse 関数からスタートして、parser_parse_document → parser_parse_in_data_state → parser_parse_html_element → parser_parse_html_regular_element → parser_parse_in_data_state ... みたいな感じで HTML 要素の階層で再帰呼び出しで処理されていそうな雰囲気を掴んだ。

RBS

Ruby の型を記述するやつ。RBS も内部で AST を作成しており再帰下降パーサな実装になっている。

github.com

lexer の実装は re2c というツールを使って正規表現から生成されているみたい。

rbs_parse_signature 関数がパースのエントリーポイントのように見える。rbs_parse_signature → parse_decl → parse_class_decl → parse_class_decl0 → parse_module_members → parse_member_def あたりを辿ると処理の雰囲気を掴める。

TinyGQL

tenderlove 氏による experimental な GraphQL パーサらしい。「recursive descent parser ruby」みたいなキーワードで GitHub 検索してたら見つけた。これまでのものが全てC言語実装だったので Ruby 実装のものも見ておきたいなということで引用。

github.com

case で分岐しながら各生成規則に対したメソッド呼び出しを再帰しているようだ。大体分かった。

ruby/json

Ruby 組み込みの JSON パーサ。JSON パーサを書きたくなったのでついでに見ておく。

github.com

json_parse_any 関数が全てを司っているように見える。なるほど。

頻出語彙の整理

パーサ実装でよく見かける語彙たち。ニュアンス分からないやつは LLM にも聞いてみた。

  • lexer ... 字句解析器、文字列をトークンに分解する
  • parser ... 構文解析器、トークンから AST (構文木)を構築する
  • token ... トークン、意味を持つ最小単位、キーワードとか識別子とか数値・記号など
  • AST ... Abstract Syntax Tree、構文木
  • node ... AST の構成要素、メタファーが「木」だからね
  • advance ... 現在の解析位置を先に進める関数に使われがち、lexerだったら文字位置、parserだったらトークン位置が進むイメージ
  • peek ... 解析位置を先に進めずに先の文字やトークンをチェックする時に使われがち、英単語の語源としては「ちらっとのぞく」

素朴な再帰下降パーサをRubyで書いてみる

ようやく本題。雰囲気が掴めたところで実際に書いてみる。

文法が巨大&複雑ではなく、かつ部分的に対応してもそれなりに動作確認可能なものということで JSON をパースする何かを目指す。あと JSON であれば AST を作る必要もなく Ruby で表現される何かしらの値 (e.g. true, false, nil, String, Integer|Flaot, {}, []) を生成すれば良い。

JSON の文法は以下ページに記載の通り。

www.json.org

バッカスナウア記法のようでバッカスナウア記法ではない、でも少しバッカスナウア記法なもので表現したのが以下の通り。

json ::= element
value ::= object | array | string | number | "true" | "false" | "null"
object ::= '{' ws '}' | '{' members '}'
members ::= member | member ',' members
member ::= ws string ws ':' element
array ::= '[' ws ']' | '[' elements ']'
elements ::= element | element ',' elements
element ::= ws value ws
string ::= '"' characters '"'
characters ::= "" | character characters
character ::= '0020' . '10FFFF' - '"' - '\' | '\' escape
escape ::= '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex hex hex hex
hex ::= digit | 'A' . 'F' | 'a' . 'f'
number ::= integer | fraction | exponent
integer ::= digit | onenine digits | '-' digit | '-' onenine digits
digits ::= digit | digit digits
digit ::= '0' | onenine
onenine ::= '1' . '9'
fraction ::= "" | '.' digits
exponent "" | 'E' sign digits | 'e' sign digits
sign ::= "" | '+' | '-'
ws ::= "" | '0020' ws | '000A' ws | '000D' ws | '0009' ws

さて、ここから truefalse だけパースできる文法を定義するならばこうなる。 ws も潔く対応しない。

json ::= element
value ::= "true" | "false"
element ::= value

初期実装

最初の実装はこう。

require "strscan"

class Lexer
  def initialize(string)
    @scan = StringScanner.new string
    @token = nil
  end

  attr_reader :token

  def advance
    @token = case
             when @scan.scan(/true/)
               :TRUE
             when @scan.scan(/false/)
               :FALSE
             end
  end
end

class Parser
  def initialize(string)
    @lexer = Lexer.new string
  end

  def parse_json
    @lexer.advance
    parse_element
  end

  def parse_element
    parse_value
  end

  def parse_value
    v = case @lexer.token
        when :TRUE
          true
        when :FALSE
          false
        end
    @lexer.advance
    v
  end
end

puts Parser.new(%|true|).parse_json.inspect
puts Parser.new(%|false|).parse_json.inspect

動かしてみる。

$ ruby parser.rb 
true
false

素数が 0 or 1 な Array に対応する

つまり繰り返しがまだ要らない。文法はこんな感じか。

json ::= element
value ::= array | "true" | "false"
array ::= '[' ']' | '[' elements ']'
elements ::= element
element ::= value

実装差分。

diff --git a/parser.rb b/parser.rb
index f282c4f..0283cea 100644
--- a/parser.rb
+++ b/parser.rb
@@ -14,6 +14,10 @@ class Lexer
                :TRUE
              when @scan.scan(/false/)
                :FALSE
+             when @scan.scan(/\[/)
+               :LBRACKET
+             when @scan.scan(/\]/)
+               :RBRACKET
              end
   end
 end
@@ -38,11 +42,31 @@ class Parser
           true
         when :FALSE
           false
+        when :LBRACKET
+          parse_array
         end
     @lexer.advance
     v
   end
+
+  def parse_array
+    array = []
+    @lexer.advance # consume LBRACKET
+    case @lexer.token
+    when :RBRACKET
+      array
+    else
+      array.concat parse_elements
+    end
+  end
+
+  def parse_elements
+    [parse_element]
+  end
 end
 
 puts Parser.new(%|true|).parse_json.inspect
 puts Parser.new(%|false|).parse_json.inspect
+puts Parser.new(%|[]|).parse_json.inspect
+puts Parser.new(%|[true]|).parse_json.inspect
+puts Parser.new(%|[[[]]]|).parse_json.inspect

実行。なんとネストした Array がパースできている。これは再帰している。

$ ruby parser.rb 
true
false
[]
[true]
[[[]]]

素数が複数の Array に対応する

繰り返し処理が必要になってくる。

json ::= element
value ::= array | "true" | "false"
array ::= '[' ']' | '[' elements ']'
elements ::= element | element ',' elements
element ::= value

差分は以下の通り。

diff --git a/parser3.rb b/parser3.rb
index 0283cea..0542c8b 100644
--- a/parser3.rb
+++ b/parser3.rb
@@ -18,6 +18,8 @@ class Lexer
                :LBRACKET
              when @scan.scan(/\]/)
                :RBRACKET
+             when @scan.scan(/,/)
+               :COMMA
              end
   end
 end
@@ -61,7 +63,13 @@ class Parser
   end
 
   def parse_elements
-    [parse_element]
+    elements = []
+    elements << parse_element
+    while @lexer.token == :COMMA
+      @lexer.advance # consume COMMA
+      elements << parse_element
+    end
+    elements
   end
 end
 
@@ -70,3 +78,5 @@ puts Parser.new(%|false|).parse_json.inspect
 puts Parser.new(%|[]|).parse_json.inspect
 puts Parser.new(%|[true]|).parse_json.inspect
 puts Parser.new(%|[[[]]]|).parse_json.inspect
+puts Parser.new(%|[true,false]|).parse_json.inspect
+puts Parser.new(%|[true,false,[[],[true],[[true,false]]]]|).parse_json.inspect
$ ruby parser.rb 
true
false
[]
[true]
[[[]]]
[true, false]
[true, false, [[], [true], [[true, false]]]]

最終ソースコード

require "strscan"

class Lexer
  def initialize(string)
    @scan = StringScanner.new string
    @token = nil
  end

  attr_reader :token

  def advance
    @token = case
             when @scan.scan(/true/)
               :TRUE
             when @scan.scan(/false/)
               :FALSE
             when @scan.scan(/\[/)
               :LBRACKET
             when @scan.scan(/\]/)
               :RBRACKET
             when @scan.scan(/,/)
               :COMMA
             end
  end
end

class Parser
  def initialize(string)
    @lexer = Lexer.new string
  end

  def parse_json
    @lexer.advance
    parse_element
  end

  def parse_element
    parse_value
  end

  def parse_value
    v = case @lexer.token
        when :TRUE
          true
        when :FALSE
          false
        when :LBRACKET
          parse_array
        end
    @lexer.advance
    v
  end

  def parse_array
    array = []
    @lexer.advance # consume LBRACKET
    case @lexer.token
    when :RBRACKET
      array
    else
      array.concat parse_elements
    end
  end

  def parse_elements
    elements = []
    elements << parse_element
    while @lexer.token == :COMMA
      @lexer.advance # consume COMMA
      elements << parse_element
    end
    elements
  end
end

puts Parser.new(%|true|).parse_json.inspect
puts Parser.new(%|false|).parse_json.inspect
puts Parser.new(%|[]|).parse_json.inspect
puts Parser.new(%|[true]|).parse_json.inspect
puts Parser.new(%|[[[]]]|).parse_json.inspect
puts Parser.new(%|[true,false]|).parse_json.inspect
puts Parser.new(%|[true,false,[[],[true],[[true,false]]]]|).parse_json.inspect

これは JSON パーサと言えるのか?

やはり object がパースできないと JSON パーサに見えないが、一応この段階でも JSON のサブセットをパースできる何かにはなっているはず。

素朴すぎないか?

Yes.

ホワイトスペースの対応とか、string や number といったリテラル値の抽出とか、終了判定とか、文法エラーの検出など実装すべきものはたくさんあるけど、全部あえて無視した。あと処理の効率化( @scan.scan あたりの文字列処理の効率化)も意識的にやっていない。あくまで「再起下降パーサの雰囲気を掴む」ということを第一目的に実装してみた。

再起下降パーサの実装として本当に合ってる?

ぶっちゃけ分からない。分からないので誰か有識者の方がいたら教えてほしい。

まとめ

素朴な再起下降パーサを Ruby で書いてみた。合ってるかどうかは分からない。

宣伝コーナー

というわけでこの続き的なことは 10/23(木) に開催する Kashiwa.rb #16 ワイガヤグループワーク会 で取り組むつもりです。

パーサに限らず、Rubyに関すること(Rubyに関さないことも)で興味あればぜひ勉強会に足を運んでみてください。それでは。