LSP ルーターを作った

最近は Emacs の LSP クライアント機能である Eglot を使って Ruby を書いたり読んだりしてる。 ruby-mode では LSP サーバーはデフォルトで Solargraph が使われてる。

半年くらい前に rubocop に LSP サーバー機能が搭載されたらしいんで使ってみた。

(add-to-list 'eglot-server-programs '(ruby-mode . ("rubocop" "--lsp")))

rubocop の機能であるコードのチェックはちゃんと使えたんだけど、Solargraph で使えてたコードジャンプとかが使えなくなった。まあそれはそう。

Eglot はモードごとに LSP サーバーを指定することはできるけど、同じモードに複数の LSP サーバーを指定することはできなそう。Emacs Lisp はよくわからないんでちゃんと調べてないんだけどたぶん。

じゃあ複数の LSP サーバーを束ねて一つの LSP サーバーのように振る舞うツールがどこかにあるんじゃないかな…と思って5分ほどググって探してみたけど見つからなかったので、自分で作ってみた。

gitlab.com

インストール:

gem install lsp_router

設定ファイル:

logfile '/tmp/lsp_router'
loglevel :info

server :rubocop do
  command 'rubocop --lsp'
end

server :solargraph do
  command 'solargraph stdio'
end

Emacs の設定:

(add-to-list 'eglot-server-programs '(ruby-mode . ("lsp_router" "--error=/tmp/lsp_router.err" "/dokka/lsp_router.conf")))

こんな感じでLSPプロトコルを振り分ける:

                             +--- rubocop
Emacs(Eglot) -- lsp_router --|
                             +--- solargraph

LSP (というか JSON-RPC?)は、Request と、それに対する Response と、応答が必要ない Notification から構成されてるらしい。

lsp_router は最初に各LSP サーバーの Capability を調べておいて、クライアントからの Request は対応するサーバーに振り分ける。Notification は全サーバーに送る。サーバーからの送信はそのままクライアントに垂れ流している。

LSP プロトコルは今回初めて軽く調べてみただけなんで変なことしてる可能性はあるけど、少なくとも Emacs からはそれなりに動いてる気配を感じてる。

余談

設定ファイルの文法は Ruby。

Ruby 3.1 から load に第2引数でモジュールを渡せるようになったのでそれを使ってる。便利。

3.1 より前で同じようなことをするには Hoge.module_eval(File.read(conf_file)) みたいな感じでやっててイマイチだったんで、満足。

文字ときどきRuby

これはRubyアドベントカレンダーSmartHRアドベントカレンダーの17日目の記事です。

qiita.com

qiita.com

12/9 に nagano.rb で文字について発表して、同じのを 12/15 に SmartHR 社内で LT しました。

スライドはこちら

speakerdeck.com

同じ文字?

この2つの文字は同じものに見えますか?

実はこれは同じ文字を異なるフォントで表示したものです。

ゴシック体と明朝体で字体が異なって見えるのと同じことなので、同じ文字と言えるでしょう。

コンピュータで扱う文字は文字ごとに番号(コードポイント)が振られていて、プログラムから見たときには同じコードポイントであれば同じ文字として扱われます。

Ruby で文字のコードポイントを得るには String#ord を使用できます。

'直'.ord.to_s(16)  #=> "76f4"
'ほげ'.chars.map{_1.ord.to_s(16)}  #=> ["307b", "3052"]

または String#unpack('U*') でも可能です。

'ほげ'.unpack('U*').map{_1.to_s(16)}  #=> ["307b", "3052"]

正規化

この2つは同じ文字でしょうか。 同じに見えますが、これは異なるコードポイントの文字です。

前者はCJK統合漢字、後者はCJK互換漢字というカテゴリに含まれています。

コードポイントが異なるので普通に比較したら不一致となりますが、

rei1 = '令'
rei2 = '令'
rei1.ord.to_s(16)  #=> "4ee4"
rei2.ord.to_s(16)  #=> "f9a8"
rei1 == rei2  #=> false

CJK互換漢字を String#unicode_normalize で正規化すると統合漢字に変換されます。

rei1 == rei2.unicode_normalize  #=> true

ユニコードの正規化は UAX #15: Unicode Normalization Forms に仕様があります。

String#unicode_normalize のデフォルトは NFC ですが、NFKC を使うと次のような変換もできます。

'0'.unicode_normalize(:nfkc) #=> '0'
'①'.unicode_normalize(:nfkc) #=> '1'
'ア'.unicode_normalize(:nfkc)  #=> 'ア'
'パ'.unicode_normalize(:nfkc) #=> 'パ'
'㌖'.unicode_normalize(:nfkc) #=> 'キロメートル'

異体字セレクタ

これは同じ文字でしょうか?

日本語に詳しければ、これは字体が異なるだけで同じ文字だということはわかるでしょう。 最初の「直」と同じです。

ですが、ここでは異体字セレクタを使った例を示します。

U+E0100〜U+E01EF が異体字セレクタです。上の例では U+E0102 です。

基底文字に異体字セレクタを追加することで文字の見た目を指定することができます。 プレーンテキストでも字体を指定できる仕組みです。

ただしちゃんと表示するには、システムとフォントが対応している必要があります。

どのような異体字があるか調べるには 異体字セレクタセレクタ が便利です。

たとえば「邊」の一覧は https://747.github.io/vsselector/#!/ja/908a で見れます。 最初に示した「直」の異体字セレクタもあります。https://747.github.io/vsselector/#!/ja/76f4

異体字セレクタは unicode_normalize では消えません。消したい場合は gsub とかで消しましょう。

str.gsub(/[\u{e0100}-\u{e01ef}]/, '')

「髙」

「髙」は俗に「はしご高」と呼ばれてる文字です。

Unicode では「髙」は「高」の異体字ではなく別の文字です。別の文字なので正規化の対象ではないし、異体字セレクタにもありません。

SJIS(Windows-31J)にも存在する文字です。なので変換も可能です。

'髙'.encode('Windows-31J')
#=> "\x{FBFC}"

'髙'.encode('SJIS')  # SJIS は Windows-31J の別名
#=> "\x{FBFC}"

でも JIS では「髙」という文字は存在しなくて「高」の異体字扱いです。対応する文字がないので変換できません。

Ruby では SJIS と Shift_JIS は異なるエンコーディングなので注意。

'髙'.encode('Shift_JIS') # Shift_JIS と SJIS は異なる
# `encode': U+9AD9 from UTF-8 to Shift_JIS
#  (Encoding::UndefinedConversionError)

「髙」は「高」と別の文字として扱う分には何も問題ないんですが、人名検索とかで「高」と同一文字として扱いたいこともあるかもしれないのでむずかしいところです。

「﨑」

「﨑」は俗に「たち崎」と呼ばれてる文字です。

これは「令」と同じく CJK互換漢字に含まれる文字です。 けど、「令」と異なり unicode_normalize では「崎」にはなりません。

'﨑'.unicode_normalize  #=> "﨑"

同じくCJK互換漢字に含まれてる「福」はちゃんと「福」に変換されます。

'福'.unicode_normalize  #=> "福"

「﨑」は何が違うかというとこういうことでした。

CJK互換漢字 - Wikipedia

なお、U+FA11(﨑)はU+5D0E(崎)、U+FA14(﨔)はU+6B05(欅)およびU+6989(榉)、U+FA1F(﨟)はU+81C8(臈)にそれぞれ統合漢字ブロックの異体字を持つが、字体差が大きいとみなされ統合の範疇とされていない

これも「髙」と同じく正規化するには個別でやる必要がありそうです。

おまけ

平仮名の「へ」と片仮名の「ヘ」がまったく同じ字体なのは日本語のバグですね。

文字数

1文字に見えるこれらの絵文字は実際には何文字でしょう?

国旗は2文字で構成されてます。

'🇯🇵'.size  #=> 2

日本の国コードは JP ですが国旗用文字の「🇯」と「🇵」をつなげて書くと「🇯🇵」となります。 同様に「🇺」と「🇸」をつなげると「🇺🇸」になります。

3人家族の絵文字はコードポイント U+1F46A の1文字です。

'👪'.size  #=> 1

ところが子供が一人増えて4人家族になるとコードポイント7文字で構成されます。

'👨‍👩‍👧‍👦'.size  #=> 7

絵文字以外にも、たとえば濁点付きのかな文字は、「ぱ」のように1文字の濁点付き文字と、「は」と「◌゚」の2文字を合成した文字があります。

人間に肌色や髪型を合成した絵文字もあります。

書記素

プログラム的に自然なのはコードポイントの数ですが、人には不自然です。

人に自然な文字の単位に「書記素」というのがあります。

書記素 - Wikipedia より

書記素(しょきそ、英: grapheme)とは、書記言語において意味上の区別を可能にする最小の図形単位をいう

Ruby では String#grapheme_clusters を使うと文字列を書記素に分割できます。

'🇯🇵👪👨‍👩‍👧‍👦'.size  #=> 10

'🇯🇵👪👨‍👩‍👧‍👦'.grapheme_clusters  #=> ["🇯🇵", "👪", "👨‍👩‍👧‍👦"]

'🇯🇵👪👨‍👩‍👧‍👦'.grapheme_clusters.size  #=> 3

また、正規表現の \X は書記素1文字に適合します。

'🇯🇵👪👨‍👩‍👧‍👦'.scan(/./)
#=> ["🇯", "🇵", "👪", "👨", "‍", "👩", "‍", "👧", "‍", "👦"]

'🇯🇵👪👨‍👩‍👧‍👦'.scan(/\X/)
#=> ["🇯🇵", "👪", "👨‍👩‍👧‍👦"]

まとめ

ユニコードは結構カオス。

文字列を比較するときは正規化した方がいいかもしれない。

文字数を数えるときはコードポイントなのか書記素なのかを考えた方がいいかも。

net-smtp 0.4.0

Ruby の SMTP ライブラリ net-smtp の 0.4.0 をリリースしたので変更内容を書いておく。

認証用のクラスを分離

今まで Net::SMTP クラス中に全部の認証用のメソッドが用意されていたんだけど、新しい認証方式に対応するたびに Net::SMTP 本体に手を入れるのは大変なので、認証用のクラスを作った。

新しい認証方式に対応するには Net::SMTP::Authenticator クラスを継承したクラスを作って、auth メソッドを書けば良い。

たとえば PLAIN 認証をサポートする net/smtp/auth_plain.rb はこんな内容になってる:

class AuthPlain < Net::SMTP::Authenticator
  auth_type :plain

  def auth(user, secret)
    finish('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}"))
  end
end

finish メソッドで SMTP サーバーに命令を送って、それが成功すれば認証が成功したとみなす。

認証が成功するまでに何回かやりとりする必要がある認証方式もある。 LOGIN 認証は、次のような流れ。

Client> AUTH LOGIN
Server> 334 プロンプト用文字列(base64)
Client> ユーザー名(base64)
Server> 334 プロンプト用文字列(base64)
Client> パスワード(base64)
Server> 235 2.7.0 Authentication successful

net/smtp/auth_login.rb はこんな感じ:

class AuthLogin < Net::SMTP::Authenticator
  auth_type :login

  def auth(user, secret)
    continue('AUTH LOGIN')
    continue(base64_encode(user))
    finish(base64_encode(secret))
  end
end

continue メソッドは finish と同じく SMTP サーバーにデータを送るけど、まだ続きがあること(3xx 応答)を期待する。

PLAIN や LOGIN は平文認証だけど、チャレンジレスポンス方式で認証情報を暗号化する認証方式もある。 サーバーからのレスポンスデータを使うには continue メソッドの戻り値を使えばいい。

たとえば CRAM-MD5 用の net/smtp/auth_cram_md5.rb はこんな感じ:

class AuthCramMD5 < Net::SMTP::Authenticator
  auth_type :cram_md5

  def auth(user, secret)
    challenge = continue('AUTH CRAM-MD5')
    crammed = cram_md5_response(secret, challenge.unpack1('m'))
    finish(base64_encode("#{user} #{crammed}"))
  end
  ...以下略...

SMTPUTF8 対応

サーバーが SMTPUTF8 拡張に対応している場合(EHLO で SMTPUTF8 を返す場合)、メールアドレスに 8bit 文字が含まれていたら MAIL FROM 時に SMTPUTF8 オプションを使うようになった。

例外が発生するのにメールが送られてることがあったのを修正

RCPT TO が 53x エラーを返した場合、例外が発生するけどそれ以外の宛先にはメールが送られることがあったのを直した。

53x 以外のエラーはそのまま例外になって終わるんで、53x エラーだけ特別扱いしてるのが謎だったので 53x エラーも特別扱いしないようにした。

そもそも 53x エラーは認証エラーなので、普通は RCPT TO で 53x エラーが返ることなんてないんでホントに謎だった。

Subversion 用のキーワードを消した

未だに Subversion のキーワード $Id$, $Revision$ が含まれてたので消した。


以上のような感じなので、まあ普通に使う分には変わりはないはず。

Ruby から Bluesky に投稿してみる

Ruby から Bluesky に投稿してみようとあれこれやってみたのでメモ。

Gem

Bluesky のプロトコルについて何もわかってないので Gem を探す。

bluesky という gem が見つかったけど、これは違うやつっぽい。 bskyrb というのがあったので使ってみる。 atproto というのもあったけどこれは調べてない。

% gem install bskyrb
Fetching bskyrb-0.5.3.gem
Fetching httparty-0.21.0.gem
Fetching xrpc-0.0.4.gem
When you HTTParty, you must party hard!
Successfully installed httparty-0.21.0
Successfully installed xrpc-0.0.4
Successfully installed bskyrb-0.5.3
Parsing documentation for httparty-0.21.0
Installing ri documentation for httparty-0.21.0
Parsing documentation for xrpc-0.0.4
unknown encoding name "'application/json'," for lib/xrpc/client.rb, skipping
Installing ri documentation for xrpc-0.0.4
Parsing documentation for bskyrb-0.5.3
Installing ri documentation for bskyrb-0.5.3
Done installing documentation for httparty, xrpc, bskyrb after 0 seconds
3 gems installed

Bskyrb

README に書かれてる通りだけどこんな感じで使う。

require 'bskyrb'
username = 'tmtms.bsky.social'
password = 'xxxxxxxxxxxxxxxxx'
pds_url = 'https://bsky.social'

credentials = Bskyrb::Credentials.new(username, password)
session = Bskyrb::Session.new(credentials, pds_url)
bsky = Bskyrb::RecordManager.new(session)

Post(投稿)

bsky.create_post('API からの投稿テスト')
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w",
#  "cid"=>"bafyreifpmd2ovx5cmytpnje35pgzr7cdkacaksxeh42nfdlwxaotehtpve"}

戻り値の uriat://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w は、https://staging.bsky.app/profile/tmtms.bsky.social/post/3jyy7fmciva2w に対応してるっぽい。よくわかってないけど。

投稿の取得

Post の URI かブラウザで見るときの URL を指定する。

bsky.get_post_by_url('at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w')
または
bsky.get_post_by_url('https://staging.bsky.app/profile/tmtms.bsky.social/post/3jyy7fmciva2w')
#=>
# #<Bskyrb::AppBskyFeedDefs::PostView:0x00007f3ffb2c8660
#  @author=
#   {"did"=>"did:plc:vgflzemtmzcqr3eyrtt4wh7c",
#    "handle"=>"tmtms.bsky.social",
#    "displayName"=>"tmtms / とみたまさひろ",
#    "avatar"=>
#     "https://cdn.bsky.social/imgproxy/JpuYoHvseSZTA4-XJl6NOTJWMrBDO46GFNfNLxAJ8aM/rs:fill:1000:1000:1:0/plain/bafkreiho7263tsofusirvy2soncqqiewwd5747qfh7655t2ydfitou37ni@jpeg",
#    "viewer"=>{"muted"=>false, "blockedBy"=>false},
#    "labels"=>[]},
#  @cid="bafyreifpmd2ovx5cmytpnje35pgzr7cdkacaksxeh42nfdlwxaotehtpve",
#  @embed=nil,
#  @indexedAt="2023-06-25T10:03:06.127Z",
#  @labels=[],
#  @likeCount=2,
#  @record=
#   {"text"=>"API からの投稿テスト",
#    "$type"=>"app.bsky.feed.post",
#    "createdAt"=>"2023-06-25T19:03:04.236+09:00"},
#  @replyCount=0,
#  @repostCount=1,
#  @uri="at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w",
#  @viewer=
#   {"repost"=>
#     "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.repost/3jyya4rb26n2m",
#    "like"=>
#     "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.like/3jyyaaxqcbv2m"}>

Like(いいね)

投稿の URI を指定して like

bsky.like('at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w')
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.like/3jyyaaxqcbv2m",
#  "cid"=>"bafyreicq3afwv4ayry7bu3plcfvmhjr5xwpkqneztsqr6y2bs5jt5fxjpq"}

Repost(再投稿)

Twitter でいうところのリツイート。

bsky.repost('at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyy7fmciva2w')
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.repost/3jyya4rb26n2m",
#  "cid"=>"bafyreihiahaprk52am5ukogenkz7xtw3npi2cu7oqwszkjudu2wxmtzi74"}

テキスト中の URL

こんな風にテキスト中に URL 文字列を含めてもリンクにはならない。

bsky.create_post("テスト https://tmtms.net")
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyyanhmjry2x",
#  "cid"=>"bafyreib24x7xh3kbjyck2d67fj7qie2w7xdxxjihl64vaf6oyauagpr6dy"}

リンクになってる投稿を取得して見てみる。

bsy.get_post_by_url('https://staging.bsky.app/profile/tmtms.bsky.social/post/3jyybr2bcdl2a')
#=>
# #<Bskyrb::AppBskyFeedDefs::PostView:0x00007f3ffb2615f0
#  @author=
#   {"did"=>"did:plc:vgflzemtmzcqr3eyrtt4wh7c",
#    "handle"=>"tmtms.bsky.social",
#    "displayName"=>"tmtms / とみたまさひろ",
#    "avatar"=>
#     "https://cdn.bsky.social/imgproxy/JpuYoHvseSZTA4-XJl6NOTJWMrBDO46GFNfNLxAJ8aM/rs:fill:1000:1000:1:0/plain/bafkreiho7263tsofusirvy2soncqqiewwd5747qfh7655t2ydfitou37ni@jpeg",
#    "viewer"=>{"muted"=>false, "blockedBy"=>false},
#    "labels"=>[]},
#  @cid="bafyreig4rfs7xdlpzq7ewjzn5itvosyccifz3sj2hhowj6mop46e7xflzq",
#  @embed=nil,
#  @indexedAt="2023-06-25T10:45:17.362Z",
#  @labels=[],
#  @likeCount=0,
#  @record=
#   {"text"=>"リンクのテスト https://tmtms.net",
#    "$type"=>"app.bsky.feed.post",
#    "facets"=>
#     [{"index"=>{"byteEnd"=>39, "byteStart"=>22},
#       "features"=>
#        [{"uri"=>"https://tmtms.net",
#          "$type"=>"app.bsky.richtext.facet#link"}]}],
#    "createdAt"=>"2023-06-25T10:45:17.066Z"},
#  @replyCount=0,
#  @repostCount=0,
#  @uri="at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyybr2bcdl2a",
#  @viewer={}>

どうやら、テキストの何バイト目から何バイト目までがリンクで URI みたいな指定がされてるぽい。投稿時に同じような指定をすればよさそう。

bskyrb のコードを読んでみたら post の代わりに create_record を使えば JSON で色々指定できるぽい。

data = {
  'collection' => 'app.bsky.feed.post',
  'repo' => session.did,
  'record' => {
    '$type' => 'app.bsky.feed.post',
    'createdAt' => Time.now.iso8601(3),
    'text' => 'ホームページはこちら',
    'facets' => [
      {
        'index' => {'byteStart' => 21, 'byteEnd' => 30},
        'features' => [
          {
            'uri' => 'https://tmtms.net/',
            '$type' => 'app.bsky.richtext.facet#link',
          },
        ],
      },
    ],
  },
}
bsky.create_record(data)
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyycpsqjuc2b",
#  "cid"=>"bafyreidsaocpktig5jxud45x3jhdtaq2755cmsoibkh4oxc2yyjeqvummi"}

「こちら」の部分がリンクになった!

リンクカード

アプリやブラウザから投稿するときに本文中に URL が含まれていると「Add link card」という表示が出て、そこをクリックすると og:image や og:title などがカード形式で表示される。 Twitter は特に何もしなくても表示されたんだけど、Bluesky は今のところ(?)「Add link card」を押さないとカードがつかないまま。

API の投稿で同じことをやるには次のような感じでやればいいっぽい。

まずサムネイル画像をアップロードする。

HTTParty.post(
  bsky.upload_blob_uri(session.pds),
  body: File.read('tengu.jpg'),
  headers: {
    "Content-Type" => 'image/jpeg',
    "Authorization" => "Bearer #{session.access_token}",
  }
)
#=>
# {"blob"=>
#   {"$type"=>"blob",
#    "ref"=>
#     {"$link"=>"bafkreifwykwd2waeshgjze5rccphfsoqfjykjv2fa6psbxc65ym2nf6id4"},
#    "mimeType"=>"image/jpeg",
#    "size"=>117643}}

Bskyrb は、bsky.upload_blob('tengu.jpg', 'image/jpeg') で上と同じことができるようにしてるっぽいんだけど、Content-Type の指定が無視されてしまうバグがあって使えなかった。そのうち直るんじゃないかな。たぶん。

この戻り値の blob を投稿時に embedthumb に指定する。

data = {
  'collection' => 'app.bsky.feed.post',
  '$type' => 'app.bsky.feed.post',
  'repo' => session.did,
  'record' => {
    '$type' => 'app.bsky.feed.post',
    'createdAt' => Time.now.iso8601(3),
    'text' => 'ブログへのリンク。画像と説明はダミー。',
    'embed' => {
      '$type' => 'app.bsky.embed.external',
      'external' => {
        'uri' => 'https://blog.tmtms.net/',
        'title' => 'tmtms のメモ',
        'description' => '天狗はブログの内容とは関係ないよ',
        'thumb' => {
          '$type' => 'blob',
          'mimeType'=>'image/jpeg',
          'size' => 117643,
          'ref' => {
            '$link' => 'bafkreifwykwd2waeshgjze5rccphfsoqfjykjv2fa6psbxc65ym2nf6id4',
          },
        },
      },
    },
  },
}
bsky.create_record(data)
#=>
# {"uri"=>
#   "at://did:plc:vgflzemtmzcqr3eyrtt4wh7c/app.bsky.feed.post/3jyyli7jvjr2y",
#  "cid"=>"bafyreiao4a7zpyqddspkv3nciiq7xhzhsjv6snmicgyp73plagj4pvmply"}

できた! 🎉🎉🎉


プロトコルのことを何もわかってないままだけど、一応ひと通りの投稿はできるようになった。

しかし、もともと Twitter の自分の投稿を拾って Bluesky に投げたいと思って調べてたんだけど、Twitter の API が使えなくなってしまったのでどうしたもんかなー…。

roda-sequel-stack を使ってみた

Ruby で ActiveRecord じゃなくて Sequel を使いたい場合のウェブフレームワークは何がいいかなぁ。Hanami かな。まあ Rails で Sequel 使ってもいいんだけども。

とつぶやいたら本人直々に

I recommend Roda+Sequel https://roda.jeremyevans.net

と、roda-sequel-stack を教えてもらったので使ってみる。

初期設定

git clone でローカルにコピー。

% git clone https://github.com/jeremyevans/roda-sequel-stack.git hoge
% cd hoge

roda-sequel-stack 自体の Git 履歴は要らないので削除して新しくリポジトリを作った方がいいかも。

% rm -rf .git
% git init
% git commit --allow-empty -m 'Initial commit'

Hoge アプリとしてセットアップ。

% rake 'setup[Hoge]'
% bundle install

デフォルトだと PostgreSQL だけど MySQL を使いたいので変更。

-gem 'sequel_pg', '>= 1.8', require: 'sequel'
+gem 'ruby-mysql', '>= 4.0', require: 'mysql'

簡単のために MySQL は Docker Compose を使用。

[docker-compose.yml]

services:
  db:
    image: mysql:8.0.33
    ports:
      - "127.0.0.1:13306:3306"
    volumes:
      - db:/var/lib/mysql
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
volumes:
  db:

データベースとユーザーを作成。

% docker-compose up -d
% docker-compose exec db mysql
mysql> create database hoge_production;
mysql> create database hoge_development;
mysql> create database hoge_test;
mysql> create user hoge;
mysql> grant all on `hoge\_%`.* to hoge;

データベース接続情報を設定。

[.env.rb]

...
-  ENV['HOGE_DATABASE_URL'] ||= "postgres:///hoge_test?user=hoge"
+  ENV['HOGE_DATABASE_URL'] ||= "mysql://hoge@127.0.0.1:13306/hoge_test"
...
-  ENV['HOGE_DATABASE_URL'] ||= "postgres:///hoge_production?user=hoge"
+  ENV['HOGE_DATABASE_URL'] ||= "mysql://hoge@127.0.0.1:13306/hoge_production"
...
-  ENV['HOGE_DATABASE_URL'] ||= "postgres:///hoge_development?user=hoge"
+  ENV['HOGE_DATABASE_URL'] ||= "mysql://hoge@127.0.0.1:13306/hoge_development"
...

rackup をインストールしてサービスを起動。

% gem install rackup
% rackup -p 8080

ここでブラウザで http://localhost:8080 にアクセスすると次のようなページが表示される。

よくあるサンプルサービスを作ってみる

マイグレーション

Article モデルを作成する。サンプルのマイグレーションファイルは削除。

% rm migrate/001_tables.rb

新しくマイグレーションファイルを作成。Sequel のマイグレーションファイルの書き方は Sequel の schema_modification.rdoc を参照。

[migrate/20230611001_articles.rb]

Sequel.migration do
  change do
    create_table :articles do
      primary_key :id
      String :title
      String :body, size: 1024
    end
  end
end

マイグレーション実行。

% rake dev_up

モデル

Article モデル作成。

[models/article.rb]

class Article < Sequel::Model
end

ルーティング(コントローラー)

ルートファイル作成。Rails でいうところのコントローラー。 書き方については Roda の README.rdoc を。

[routes/articles.rb]

class Hoge
  hash_branch('articles') do |r|
    r.is do
      @articles = Article.all
      view 'index'
    end
  end
end

ビュー

ビューファイル作成。erb ファイルなので Rails のビューファイルとだいたい同じ。

[views/articles/index.erb]

<table>
    <tr>
        <th>タイトル</th>
        <th>作成日時</th>
    </tr>
    <% @articles.each do |article| %>
        <tr>
            <td><%= article.title %></td>
            <td><%= article.created_at %></td>
        </tr>
    <% end %>
</table>

これで http://localhost:8080/articles にアクセスすると記事の一覧が表示されるはずだけど、まだデータが何も無いので何も表示されない。

IRB(コンソール)

rake dev_irb で irb を起動して Article オブジェクトを作ってみる。Rails の rails console みたいなもの。

% rake dev_irb
irb(main):001:0> Article.create(title: 'ほげほげほげほげ', body: "1行目\n2行目", created_at: Time.now)

表示された。

CSS

CSS をいじってテーブルに枠線をつけてみる。

[assets/css/app.scss]

th {
    border: solid 1px
}
td {
    border: solid 1px
}

リンク

タイトルをクリックすると本文が見れるようにしてみる。

[views/articles/index.erb]

-            <td><%= article.title %></td>
+            <td><a href="/articles/<%=article.id%>"><%= article.title %></a></td>

これでタイトルがリンクになった。でもちょっとアレなんで、link_to プラグインを使ってみる。

Article オブジェクトのパスを設定。

[app.rb]

  plugin :link_to
  path Article do |article|
    "/articles/#{article.id}"
  end

link_to メソッドを呼ぶようにビューを書き換え。

[views/articles/index.erb]

-            <td><a href="/articles/<%=article.id%>"><%= article.title %></a></td>
+            <td><%== link_to(article.title, article) %></a></td>

少し簡単になった。

リンクを押したときに本文を表示するためにルートファイルを変更してビューファイルを作成。

[routes/articles.rb]

class Hoge
  hash_branch('articles') do |r|
    r.is do
      @articles = Article.all
      view 'index'
    end
    r.is Integer do |id|
      @article = Article.with_pk!(id)
      view "show"
    end
  end
end

[views/articles/show.erb]

<dl>
    <dt>タイトル</dt>
    <dd><%= @article.title %></dd>
    <dt>作成日時</dt>
    <dd><%= @article.created_at %></dd>
    <dt>本文</dt>
    <textarea readonly><%= @article.body %></textarea>
</dl>

新規登録

さっきは irb から記事レコードを作ったけどブラウザから登録できるページを作ってみる。

[views/articles/new.erb]

<form method="post" action="/articles">
<dl>
    <dt>タイトル</dt>
    <dd><input type="text" name="title"></dd>
    <dt>本文</dt>
    <dd><textarea name="body"></textarea></dd>
</dl>
<input type="submit">
</form>

ルートファイルはこんな感じ。

[routes/articles.rb]

class Hoge
  hash_branch('articles') do |r|
    r.is do
      @articles = Article.all
      view "index"
    end
    r.is Integer do |id|
      @article = Article.with_pk!(id)
      view "show"
    end
    r.is 'new' do
      view "new"
    end
  end
end

これで http://localhost:8080/articles/new にアクセスすると新規登録画面が表示される。

POST を受け付けるようにルートファイルを変更。

[routes/articles.rb]

class Hoge
  hash_branch('articles') do |r|
    r.is do
      r.post do
        Article.create(title: r.params['title'], body: r.params['body'], created_at: Time.now)
        r.redirect
      end
      r.get do
        @articles = Article.all
        view "index"
      end
    end
    r.is Integer do |id|
      @article = Article.with_pk!(id)
      view "show"
    end
    r.is 'new' do
      view "new"
    end
  end
end

でも実際に POST してみると Invalid Security Token というエラーになってしまう。

ビューファイルに csrf_tag を追加。

[views/articles/new.erb]

<form method="post" action="/articles">
<dl>
    <dt>タイトル</dt>
    <dd><input type="text" name="title"></dd>
    <dt>本文</dt>
    <dd><textarea name="body"></textarea></dd>
</dl>
<%== csrf_tag('/articles') %>
<input type="submit">
</form>

これであたらしい記事を作成できるようになった。

おわり

roda-sequel-stack は gem でもないし、Rails みたいなフルスタックフレームワークというよりは、Roda と Sequel やその他ライブラリを使ってフルスタックフレームワークを作るためのサンプルみたいな感じかも。

Sequel は10年以上使ってるし、Roda もなかなか良さそうなので、Rails よりも好きかも。

RubyKaigi 2023

2023-05-11〜13と RubyKaigi 2023 が松本で開催されて、行ってきたのでその記録。一ヶ月以内に書いた!えらい!

松本開催

RubyKaigi は 2016年から地方都市で開催されてる。地方に行くには大都会発着だとそれなりに便利なんだけど、地方地方間の移動は結構大変。自分は長野県北部在住なんだけどどこかに行くには結局東京経由で行くのが一番早いことが多かったりする。 前回の津は自宅からだと4時間くらい、その前の福岡は6時間くらい(当時は東京に住んでたので実際にはそんなには掛かってない)。

ところが今年は松本開催ですよ。近い! とは言ってもうちから1時間以上掛かるんで「地元開催」という感じではないんだけども。

地方で開催されるようになって、京都、広島、仙台、福岡と大都市で開催されてたんだけど、前回の津で初の大都市以外、今回の松本は初の県庁所在地以外の開催ということでなかなかチャレンジング。 (とは言っても松本市は県庁所在地の長野市よりも街だしな…とか言うと怒られそうだけど)

松本駅には歓迎の横断幕が。

会場は「まつもと市民芸術館」。おしゃれ。屋上は芝生で山が綺麗に見える。

全日フルでセッション聞いてたんだけど、時間が経って忘れてしまったので覚えてるものとツイートだけ。 あとで録画が公開されたら聞けなかったのも含めて見直してみよう(と毎回思うんだけど毎回見てない)。

1日目

Matz Keynote

まつもとさんがいつもの ThinkPad じゃなくて Mac でプレゼンしてたんで、まつもとさんが席に戻ったときにとうとう宗旨替えしたのかと聞いてみたら、ThinkPad のアダプタを忘れて電池がなくなったからとのこと。 スライドの下にウサギとカメが出てなかったのは Mac の Rabbit がうまくうごかなくて PDF を表示していたかららしい。

昼食

最近の RubyKaigi は街からちょっと離れたところで開催されてたんで、昼食は会場で弁当が配られたりしてたんだけど、今回は会場が街の中にあるんで街なかの食事処で食べてってことで、入場者に 1000円の金券が3枚配られた。

駅の方に行くと混んでるだろうと思って県内の Rubyist たちとイオンモールへ。

「炙り牛たん万」

The future vision of Ruby Parser

Develop chrome extension with ruby.wasm

自分も最近 ruby.wasm で遊んでるんで共感できた。

"Ractor" reconsidered

英語なんでよくわからんかった。

Power up your REPL life with types

Lightning Talks

Building Ruby Native Extention using Ruby

RBS meets LLMs - Type inference using LLM

Customize your Vim/Neovim directly with Ruby

mruby VM

Natsukantou the XML translator

BINGO!

Adding custom rule for Rubocop in the 2 month of employment

Unexplored Region - parse.y -

Ultra-fast test-driven development

Optimizing Ruby’s Memory Layout: Variable Width Allocation

Dividing and Managing: The Cops Squad of RuboCop RSpec Dept

Serverless IdP for small team

Official Party

ホテルブエナビスタで豪華立食パーティー。

2日目

Learn Ractor

Implementing "++" operator, stepping into parse.y

昼食

またイオンモール。「富寿し」

Fix SQL N+1 queries with RuboCop

Revisiting TypeProf - IDE support as a primary feature

Multiverse Ruby

ずっと前に load の第二引数にモジュールを指定できれば便利なのになーと思ってたのが 3.2 でできるようになってた。

Eliminating ReDoS with Ruby 3.2

Optimizing YJIT’s Performance, from Inception to Production

英語なんでなんもわからんかった。

Leaner Drinkup at RubyKaigi 2023

美味しい日本酒がたくさんあった。飲みすぎて記憶が…。いつの間にかホテルに戻ってた。

3日目

Ruby Committers and The World)

英語なんでなんもわからんかった。

Build Your Own SQLite3

昼食

またまたイオンモール。結局3日ともイオンモールだった。

「小木曽製粉所」

Ruby + ADBC - A single API between Ruby and DBs

Load gem from browser

自分も最近 ruby.wasm で遊んでるんで参考になった。

Unleashing the Power of Asynchronous HTTP with Ruby

Rethinking Strings

Parsing RBS

After party

松本つなぐ横丁」で飲み食い放題。歩くのも大変なくらいのすごい賑わいだった。

RubyMusicMixin 2023

いくつもりはなかったんだけど、若者に強制的に連れて行かれた。 松田さんに挨拶ができてよかった。

おわり

次回は 2024-05-15〜17 那覇。

ruby.wasm で await を使う

最近はずっと ruby.wasm で遊んでます。

2023/5/19 に ruby.wasm 2.0 が出ました

ruby.wasm 1.0 では await がうまく動かないことがあったけど、2.0 でちゃんと動くようになったんで、記念に前の記事以降にやったこと等をまとめてみた。

await

ruby.wasm で await を使うには2つ問題がある。

  1. Ruby スクリプトを eval ではなく evalAsync で実行する必要がある。
  2. スタックサイズが小さくてすぐに SystemStackError エラーが出てしまう。

Ruby スクリプトを eval ではなく evalAsync で実行する必要がある

HTML 内で <script type="text/ruby"> で気軽に Ruby スクリプトを書いたときに await を使うとエラーになってしまう。(ruby.wasm 1.0 ではエラーにならずに nil が返される)

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    p JS.global.fetch('hoge.html').await
    #=> JS::Object#await can be called only from evalAsync (RuntimeError)
  end
  start
</script>

Ruby スクリプトを実行する部分を eval から evalAsync に変えればいいんだけど、面倒くさい。 実は evalAsync は Fiber 内で eval してるので、await したいコードを Fiber 内で動かせばいいだけだったりする。

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    p JS.global.fetch('hoge.html').await
    #=> [objcet Response]
  end
  Fiber.new{start}.transfer
</script>

簡単!

HTLM 要素にイベントハンドラを設定するには addEventListener を使うんだけど、イベントハンドラ中ではそのままでは await を使えない。

<input id="b" type="button" value="button"></input>
<script type="text/ruby">
  require 'js'
  Document = JS.global[:document]
  b = Document.getElementById('b')
  b.addEventListener('click') do |e|
    p JS.global.fetch('hoge.html').await
    #=> in `await': JS::Object#await can be called only from evalAsync (RuntimeError)
  end
</script>

この場合も Fiber を使えば await を使うことができる。

<input id="b" type="button" value="button"></input>
<script type="text/ruby">
  require 'js'
  Document = JS.global[:document]
  b = Document.getElementById('b')
  b.addEventListener('click') do |e|
    Fiber.new do
      p JS.global.fetch('hoge.html').await
      #=> [objcet Response]
    end.transfer
  end
</script>

スタックサイズが小さくてすぐに SystemStackError エラーが出てしまう

Fiber のスタックサイズが小さいので、こんなスクリプトでも SystemStackError が出ちゃう。

<script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    pp [1] * 30
    #=> Uncaught (in promise) Error: SystemStackError: stack level too deep
  end
  Fiber.new{start}.transfer
</script>

これはもうどうしようもないので、ruby.wasm をビルドし直す必要がある。めんどくさい。

https://github.com/ruby/ruby.wasm/blob/0862cab421d5419e247a7a756b4312cb89011f65/packages/npm-packages/ruby-wasm-wasi/src/browser.ts#L73

ここの new WASI({});

  const wasi = new WASI({
    env: { "RUBY_FIBER_MACHINE_STACK_SIZE": "1048576" }
  });

のように変更してビルドする。

でもめんどくさいので、簡単にやるなら、

curl https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0/dist/browser.script.iife.js |
sed -e 's/const wasi = new s.*/const wasi = new s({env:{"RUBY_FIBER_MACHINE_STACK_SIZE":"1048576"}});/' > browser.script.iife.js

のようにして、ビルド済みのファイルの中を強制的に置換したものを使うという手もある。

<script src="browser.script.iife.js"></script>
<script type="text/ruby">
  require 'js'
  def start
    pp [1] * 30
  end
  Fiber.new{start}.transfer
</script>

とここまで書いて、ruby.wasm の main だとデフォルトで 16777216 に変更されていることに気がついた。次のバージョンでは何もしなくても使えるようになるかも。

https://github.com/ruby/ruby.wasm/blob/394841d142fabc2287e7f918a605c7009e545846/packages/npm-packages/ruby-wasm-wasi/src/browser.ts#L73-L81

デイリービルドのやつでよければ、https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@2.0.0-2023-05-21-a/dist/browser.script.iife.js を使うだけでいけた。

Ruby 風にする

その他、いろいろ Ruby っぽく書けるようにした。

関数名をスネークケースで呼び出し

ruby.wasm の JS ライブラリは薄いラッパーなので、getElementById() みたいに JavaScript の関数名がそのまま使われる。

あまり Ruby ぽくないので、get_element_by_id() みたいなスネークケースでも呼べるようにした。

module JSrb
  def method_missing(sym, *args, &block)
    sym = sym.to_s.gsub(/_([a-z])/){$1.upcase}.intern
    super(sym, *args, &block)
  end
end

class JS::Object
  prepend JSrb
end

みたいな感じ。

プロパティを .prop_name 形式でも参照

JS::Object のプロパティを参照するには [:propName] のようにするけど、JS::Object.prop_name でも参照したい。

prop_namepropName に変換して [:propName] を呼び出せばよさそう。

module JSrb
  def method_missing(sym, *args, &block)
    sym = sym.to_s.gsub(/_([a-z])/){$1.upcase}.intern
    v = self.method(:[]).super_method.call(sym.intern)
    v = self.call(sym, *args, &block) if v.typeof == 'function'
    v
  end
end

こんな感じで。プロパティが関数の場合はそれを呼び出してる。

プロパティを設定するには、sym= で終わってる場合に同じような感じで []= を呼べばいい。

プリミティブ型を Ruby のオブジェクトに変換

JavaScript の値は Ruby からは JS::Object として見えるので、そのままだと使いにくい。 値の型に応じて JS::Object#to_iJS::Object#to_s みたいにして使うことになる。

これは、ベタだけど JS::Object#typeof を見て変換する感じで。

    case v.typeof
    when 'number'
      v.to_s =~ /\./ ? v.to_f : v.to_i
    when 'bigint'
      v.to_i
    when 'string'
      v.to_s
    when 'boolean'
      v.to_s == 'true'
    else
      if v.to_s =~ /\A\[object .*(List|Collection)\]\z/
        v.length.times.map{|i| v[i]}
      elsif v == JS::Null || v == JS::Undefined
        nil
      else
        v
      end
    end

〜List とか 〜Collection という名前のオブジェクトは Array にしたり、nullundefinednil にしたり。 この辺はかなりテキトーなので、うまく動かないこともあるかもしれない。

まとめ

これで、次のように書いてたスクリプトは、

children = JS.global[:document].getElementById('hoge')[:children]
children[:length].to_i.times do |i|
  p children[i][:tagName]
end

次のように書けるようになる。

JS.global.document.get_element_by_id('hoge').children.each do |c|
  p c.tag_name
end

かなり Ruby っぽくなった。

Ruby ぽくするやつの全体は https://mysql-params.tmtms.net/lib/jsrb.rb においてある。今後も変更していくと思うけど参考までに。

Rabbit on Firefox

Rabbit というプレゼンツールがある。画面の下の方でウサギとカメが追いかけっこをしてるプレゼンツールで、Ruby で作られててまつもとさんがよく使ってる。

www.youtube.com

PDF 表示時にもこれと同じようなウサギとカメを表示できないかなーと思っていろいろいじってたらできたのでメモ。

Firefox のブックマークレットを使って ruby.wasm で rb ファイルを読み込む形で実装してみた。最近自分の中で ruby.wasm が流行りなので、JavaScript でできることをわざわざ Ruby でやってる。

ブックマークレットはこんな感じ:

javascript:(()=>{if(typeof rubyVM!='undefined'){rubyVM.eval('start');return};var d=document;var h=d.getElementsByTagName('head')[0];var s=d.createElement('script');s.src='https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js';h.appendChild(s);s=d.createElement('script');s.src='https://tmtms.net/rabbit/rabbit_firefox_pdf.rb';s.type='text/ruby';h.appendChild(s);var x=setInterval(()=>{if(typeof rubyVM != 'undefined'){try{rubyVM.eval('start');clearInterval(x)}catch(e){}}},500)})()

このブックマークレットの中で https://tmtms.net/rabbit/rabbit_firefox_pdf.rb を読み込んで実行してる。

Firefox で PDF を表示してる状態でブックマークレットを実行するとウサギが表示される。

ウサギは現在表示されてるページの位置を表してる。

PDF の URL に ?allotted_time=n をつけるとカメが現れる。

最初は止まってるけど、2ページ目以降を表示するとカメが動き出す。allotted_time は時間(分)を表していて、カメはその時間を掛けて画面の左端から右端に進む。

ウサギがカメよりも遅れてたら時間内に発表を終えられない可能性がある。

もう一度ブックマークレットを実行するとカメの位置がリセットされる。

Linux で作ってたんだけど Mac の Firefox でも動くことは確認した。Windows はわからない。

信頼できない第三者の外部ファイルを読み込むブックマークレットというのはセキュリティ的に危ない気がするので、もし試してみたいという奇特な人は、ファイルをローカルにコピーして中身をよく読んでから実行する方がいいと思う。

ついでに Google Slides 用のブックマークレットも作ってみた。使い方は PDF と同じ。スライドショーを実行しないとウサギは現れない。これはちょっと動きが怪しい気もする。

javascript:(()=>{if(typeof rubyVM!='undefined'){rubyVM.eval('start');return};var d=document;var h=d.getElementsByTagName('head')[0];var s=d.createElement('script');s.src='https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js';h.appendChild(s);s=d.createElement('script');s.src='https://tmtms.net/rabbit/rabbit_google_slides.rb';s.type='text/ruby';h.appendChild(s);var x=setInterval(()=>{if(typeof rubyVM != 'undefined'){try{rubyVM.eval('start');clearInterval(x)}catch(e){}}},500)})()

ruby.wasm で MySQL Parameters を作り直した

プライベートでは基本的に誰の役にも立たないプログラムを作ってるんだけど、たまにうっかり MySQL Parameters みたいな役に立つものを作ってしまう。

MySQL Parameters は5年くらい前に Vue.js の勉強のために作ってみたんだけど、結局そのまま Vue.js は触らず放置状態だった。MySQL の新しいバージョンが出るたびにデータは更新してたけど。

ruby.wasm で Ruby が WebAssembly 上で動くようになり、ブラウザ上で JavaScript の代わりに使えるようになったんで、MySQL Parameters を Ruby で作り直してみた。

ruby.wasm

ruby.wasm のページに載ってるけど、これだけでブラウザ上で Ruby が動く。簡単。

<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
  <script type="text/ruby">
    puts "Hello, world!"
  </script>
</html>

HTML内の <script>〜</script> にベタに Ruby を書くのもつらいので、別ファイルにしてそれを読み込むようにした。

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
    <script type="text/ruby" src="hoge.rb"></script>
  </head>
  <body>
    ...
    <script type="text/ruby">
      fuga
    </script>
  </body>
</html>

こんな風にしたら hoge.rb を読み込んだ状態で fuga を実行してくれる。

p や puts 等の標準出力への出力はブラウザのコンソールに出力される。デバッグに便利。

JavaScript の機能を使う

require 'js' とすると JavaScript の機能を使うことができるようになる。

JS.eval で JavaScript を実行できる。

require 'js'
JS.eval('alert("hoge")')

JS.global 経由で JavaScript のグローバルなオブジェクトや関数を取得できる。

JS.global.alert('hoge')

JavaScript のオブジェクトや戻り値は Ruby ではすべて JS::Object クラスのインスタンスになってる。

n = JS.eval('return 123')     #=> JS::Object (123)
n.typeof                      #=> "number"
s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s.typeof                      #=> "string"

そのままでは Ruby では扱いにくいので必要に応じて to_ito_s 等で変換する。

JS::Object#[] でプロパティを取得&設定できる。プロパティ名は文字列でもシンボルでも可能っぽい。JavaScript みたいに obj.propname では参照できない。Ruby だとこれはメソッド呼び出しになっちゃうので。

JS::Object#call で JavaScript の関数を呼ぶことができる。Ruby オブジェクトに存在しないメソッドを呼んだ場合は JavaScript の同名関数に変換してくれるので便利。

s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s[:length]                    #=> JS::Object (4)
s.call(:charAt, 2)            #=> JS::Object ("g")
s.charAt(2)                   #=> JS::Object ("g")

あと JavaScript の null は Ruby は nil じゃなくて JS::Null として見える。同様に undefinedJS::Undefined。これらも JS::Object のインスタンス。

DOM 操作

Ruby にブラウザフロントエンドのフレームワークなんて当然あるわけないので、いにしえのコテコテな DOM 操作。メソッド名が JavaScript 風のキャメルケースなのでちょっと気持ち悪い。

require 'js'
Document = JS.global[:document]
hoge = Document.getElementById('hoge') # id=hoge 要素を取得
fuga = Document.createElement('div')   # div 要素を作成
fuga[:id] = 'fuga'                     # id=fuga を設定
hoge.appendChild(fuga)                 # fuga を hoge の子とする

上に書いたように戻り値は全部 JS::Object なので、たとえばある要素の子要素リストに Ruby からアクセスするには、こんな風にしないといけない。

children = JS.global[:document].getElementById('hoge')[:children]
children[:length].to_i.times do |i|
  p children[i][:tagName]
end

あんまり Ruby ぽくないので、何かしらのラッパークラスとかを用意したほうがいいかもしれない。

イベント処理

要素にイベントを設定する場合はこんな感じ。JavaScript で関数型の引数は Ruby では Proc で渡す。

element.addEventListener('change', ->(event){p event[:target][:value]})

Proc の代わりにブロックで指定することもできる。

element.addEventListener('change') do |event|
  p event[:target][:value]
end

Ruby っぽくて良い。

Promise

Promise も JavaScript オブジェクトなので、Ruby から使うことができる。

JS::Object.undef_method(:then)   # then メソッドを削除
Promise = JS.global[:Promise]
Promise.resolve(123).then do |a|
  pp a  #=> 123
end

そのままだと then で Ruby の Object#then が呼ばれちゃうので削除してる。まあ削除しなくても .call(:then) とすれば呼ぶことはできるんだけども。

fetch で外部の JSON ファイルを読み込んで Ruby の Hash にするにはこんな感じ。

require 'json'
JS.global.fetch('hoge.json').then do |res|
  # ①
  res.json.then do |obj|
    # ②
    pp JSON.parse(JS.global[:JSON].stringify(obj).to_s)
  end
  # ③
end
# ④

Promise は非同期処理なので、④→①→③→② の順に実行される

JS::Object#await を使えば JavaScript の await と同じように Promise 処理を待つことができる。ただし、rubyVM.evalAsync() 内で動かす必要がある。

def hoge
  res = JS.global.fetch('hoge.json').await
  obj = res.json.await
  p JSON.parse(JS.global[:JSON].stringify(obj).to_s)
end
JS.global[:rubyVM].evalAsync('hoge')

でもすぐに SystemStackError: stack level too deep エラーが出てしまう。たとえば pp メソッドを使うだけでもエラーになる。

https://github.com/ruby/ruby.wasm/issues/133 によると、evalAsync は Fiber を使って await を実装してて、ruby.wasm では Fiber のスタックサイズが小さいために発生しやすいらしい。

回避策はあるみたいだけど面倒そうなので MySQL Parameters では await は使わなかった。

おまけ

さすがに Vue.js のときより遅くなったので、処理に時間が掛かる場合にはイルカをくるくる回すようにしてみた。

JavaScript の時はなんとなく面倒であんまりいじる気が起きなかったんだけど、Ruby になったので色々いじってみるかなーと思ってたりする。

Ruby: SSL_CTX_load_verify_file: system lib (OpenSSL::SSL::SSLError)

OpenSSL まわりでちょっとハマったのでメモ。 こんな Ruby のコードを動かすと環境によって warning になったりエラーになったりする。

require 'faraday'
ssl_opts = {ca_file: OpenSSL::X509::DEFAULT_CERT_FILE}
Faraday::Connection.new('https://tmtms.net', ssl: ssl_opts).get
puts 'OK'

環境1:

warning になるが成功する。

% ruby -w hoge.rb
/usr/local/lib/ruby/3.1.0/net/http.rb:1081: warning: can't set verify locations
OK

環境2:

エラーになる。

% ruby -w hoge.rb
/usr/lib/ruby/3.0.0/net/http.rb:1030:in `initialize': SSL_CTX_load_verify_file: system lib (Faraday::SSLError)
    from /usr/lib/ruby/3.0.0/net/http.rb:1030:in `new'
    from /usr/lib/ruby/3.0.0/net/http.rb:1030:in `connect'
    from /usr/lib/ruby/3.0.0/net/http.rb:970:in `do_start'
    from /usr/lib/ruby/3.0.0/net/http.rb:959:in `start'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:112:in `request_with_wrapped_block'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:102:in `perform_request'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:66:in `block in call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/adapter.rb:45:in `connection'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:65:in `call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/request/url_encoded.rb:25:in `call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/rack_builder.rb:153:in `build_response'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/connection.rb:445:in `run_request'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/connection.rb:200:in `get'
    from hoge.rb:3:in `<main>'
/usr/lib/ruby/3.0.0/net/http.rb:1030:in `initialize': SSL_CTX_load_verify_file: system lib (OpenSSL::SSL::SSLError)
    from /usr/lib/ruby/3.0.0/net/http.rb:1030:in `new'
    from /usr/lib/ruby/3.0.0/net/http.rb:1030:in `connect'
    from /usr/lib/ruby/3.0.0/net/http.rb:970:in `do_start'
    from /usr/lib/ruby/3.0.0/net/http.rb:959:in `start'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:112:in `request_with_wrapped_block'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:102:in `perform_request'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:66:in `block in call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/adapter.rb:45:in `connection'
    from /var/lib/gems/3.0.0/gems/faraday-net_http-3.0.2/lib/faraday/adapter/net_http.rb:65:in `call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/request/url_encoded.rb:25:in `call'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/rack_builder.rb:153:in `build_response'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/connection.rb:445:in `run_request'
    from /var/lib/gems/3.0.0/gems/faraday-2.7.2/lib/faraday/connection.rb:200:in `get'
    from hoge.rb:3:in `<main>'

Faraday 使わずに OpenSSL だけで再現させるとこんな感じ。

require 'openssl'
sock = Socket.tcp('tmtms.net', 443)
store = OpenSSL::X509::Store.new
store.set_default_paths
ctx = OpenSSL::SSL::SSLContext.new
ctx.set_params(ca_file: OpenSSL::X509::DEFAULT_CERT_FILE, cert_store: store)
tls = OpenSSL::SSL::SSLSocket.new(sock, ctx)
tls.hostname = 'tmtms.net'
tls.sync_close = true
tls.connect
puts 'OK'

環境1:

% ruby -w hoge.rb
hoge.rb:7: warning: can't set verify locations
OK

環境2:

% ruby -w hoge.rb
hoge.rb:7:in `initialize': SSL_CTX_load_verify_file: system lib (OpenSSL::SSL::SSLError)
    from hoge.rb:7:in `new'
    from hoge.rb:7:in `<main>'

エラーになるのは、Ruby の openssl ライブラリが 3.0 で、かつ OS の OpenSSL ライブラリ(libssl)が 3.x で、OpenSSL::X509::DEFAULT_CERT_FILE のファイルが存在していない場合。たとえば、Ubuntu 22.04 とか。

Ubuntu では以前から OpenSSL::X509::DEFAULT_CERT_FILE が存在ないファイル(/usr/lib/ssl/cert.pem)になってるんだけど、Ubuntu 22.04 で libssl が 3.0 になって発生するようになった。

libssl の問題かと思ったんだけど、調べてみたら Ruby の openssl の方だった。

https://github.com/ruby/openssl/blob/c263cd40057fd4a7ea36ceefc7ad88054ed6ffea/ext/openssl/ossl_ssl.c#L882-L892

#ifdef HAVE_SSL_CTX_LOAD_VERIFY_FILE
    if (ca_file && !SSL_CTX_load_verify_file(ctx, ca_file))
        ossl_raise(eSSLError, "SSL_CTX_load_verify_file");
    if (ca_path && !SSL_CTX_load_verify_dir(ctx, ca_path))
        ossl_raise(eSSLError, "SSL_CTX_load_verify_dir");
#else
    if(ca_file || ca_path){
    if (!SSL_CTX_load_verify_locations(ctx, ca_file, ca_path))
        rb_warning("can't set verify locations");
    }
#endif

ca_file が読めないときに OpenSSL 3.x (HAVE_SSL_CTX_LOAD_VERIFY_FILE が真) だと SSLError 例外になるけど、OpenSSL 1.x だと warning になる。

ca_file に存在しないファイルを指定するのが悪いんだけど、Ubuntu では OpenSSL::X509::DEFAULT_CERT_FILE が存在してないファイルというところが罠。

デフォルトでいいなら ca_file は指定しない方がいいんだろうな。

3.2 rc1 以降の変更

Ruby 3.2 アドベントカレンダーの最終日記事です。

qiita.com


3.2 rc1 以降の変更

今年も 12/25 に無事 Ruby 3.2.0 がリリースされた 🎉

ということで、3.2 rc1 以降の変更を、3.2.0-rc1 の NEWS.md3.2.0 リリースの NEWS.md の差分からピックアップ。 3.2 rc1 でも動きが変わってたのに NEWS.md に記載されてなかったものも含む。

Data#with 追加

Feature #19000: Data: Add "Copy with changes method" [Follow-on to #16122 Data: simple immutable value object] - Ruby master - Ruby Issue Tracking System

3.2 で新しく導入された Data クラスに、リリース3日前に with メソッドが追加された。

Data オブジェクトは immutable で値の変更はできないんだけど、with で一部の値を変更した新しい Data オブジェクトを返すことができるようになった。

Hoge = Data.define(:x, :y)
hoge = Hoge.new(x: 123, y: 456)
fuga = hoge.with(y: 789)
hoge  #=> #<data Hoge x=123, y=456>
fuga  #=> #<data Hoge x=123, y=789>

便利。

IO.new (IO.for_fd) に path オプション追加 / IO#path 追加

Feature #19036: Provide a way to set path for File instances created with for_fd - Ruby master - Ruby Issue Tracking System

IO.for_fd でファイルディスクリプタから IO オブジェクトを作ることができるけど、そのときに path オプションでファイル名を指定することができるようになった。

今まではファイル名が不明なオブジェクトで path を呼ぶと例外が発生していた:

f = File.open("hoge.txt")
f2 = File.for_fd(f.fileno)
f2.path  #=> File is unnamed (TMPFILE?) (IOError)

Ruby 3.2 では例外が発生せずに nil が返る:

f = File.open("hoge.txt")
f2 = File.for_fd(f.fileno)
f2.path  #=> nil

for_fd に path を指定するとそれが返る:

f = File.open("hoge.txt")
f2 = File.for_fd(f.fileno, path: "fuga.data")
f2.path  #=> "fuga.data"

この変更は File じゃなくて IO に対して行われたので、File だけじゃなくて IO 系のオブジェクト全部で path が使えるようになってる。

$stdin.path   #=> "<STDIN>"
$stdout.path  #=> "<STDIN>"
$stderr.path  #=> "<STDERR>"
r, w = IO.pipe
r.path        #=> nil
w.path        #=> nil
TCPSocket.new("localhost", 25).path  #=> nil

Regexp.linear_time? 追加

Feature #19194: Add Regexp.linear_time? - Ruby master - Ruby Issue Tracking System

Regexp.linear_time? で正規表現のマッチング処理が線形かどうかを確認できるようになった。

Regexp.linear_time?(/a/)       #=> true
Regexp.linear_time?(/(a)*\1/)  #=> false

String#dedup 追加

文字列オブジェクトは単項演算子の - で immutable な文字列を作ることができる。

s = 'abc'
x = -s
s.frozen?  #=> false
x.frozen?  #=> true
x.upcase!  #=> can't modify frozen String: "abc" (FrozenError)

これと同じことをする dedup というメソッドが追加された。

s = 'abc'
x = s.dedup
s.frozen?  #=> false
x.frozen?  #=> true
x.upcase!  #=> can't modify frozen String: "abc" (FrozenError)

Thread::Queue#pop, Thread::SizedQueue#pop, #push に timeout オプション追加

Feature #18774: Add Queue#pop(timeout:) - Ruby master - Ruby Issue Tracking System

Feature #18944: Add SizedQueue#push(timeout:) - Ruby master - Ruby Issue Tracking System

Queue#pop に timeout オプションを追加するとタイムアウトして nil を返すようになった。

q = Queue.new
q.push 123
Thread.new{ sleep }
q.pop  #=> 123
q.pop(timeout: 1)  #=> nil

ちなみに上のコードを Ruby 3.1 で実行すると queue empty (ThreadError) という例外が発生する。 Queue#pop に真の引数を指定するとキューが空だと ThreadError になるんだけど、3.1 までの pop はキーワード引数を受け付けないので、Hash オブジェクトという引数として渡される。Hash オブジェクトは真偽値的には真なので、ThreadError になる。

SizedQueue#pop も同じ。SizedQueue#push もブロックされるので同様に timeout オプションが追加された。

Time.new の引数に文字列を指定可能

Feature #18033: Time.new to parse a string - Ruby master - Ruby Issue Tracking System

Time.new に日時風文字列引数を渡すとパースしてくれるようになった。

Time.new('2022-12-25 01:23:45')   #=> 2022-12-25 01:23:45 +0900
Time.new('2022-12-25T01:23:45Z')  #=> 2022-12-25 01:23:45 UTC

便利。年月日時分秒がちゃんと区切られてる文字列の場合は Time.parse 使わなくてもよくなった。

20221225012345 みたいな文字列はダメ。

Time.new('20221225012345')    #=> 20221225012345-01-01 00:00:00 +0900

require 'time'
Time.parse('20221225012345')  #=> 2022-12-25 01:23:45 +0900

String#to_c で _ の連続の扱いが変わった

Bug #19087: String#to_c supports multiple "_" - Ruby master - Ruby Issue Tracking System

文字列を数値に変換する処理は、数値文字列中の _ は無視するんだけど、_ の連続は不正文字としてそこで評価が終わるようになってる。けど、Ruby 3.1 では String#to_c だけは __ も無視するようになっていた。

'1__234'.to_i  #=> 1
'1__234'.to_f  #=> 1.0
'1__234'.to_r  #=> (1/1)
'1__234'.to_c  #=> (1234+0i)

Ruby 3.2 では、これを他に合わせて __ 以降は処理しないようになった。

'1__234'.to_i  #=> 1
'1__234'.to_f  #=> 1.0
'1__234'.to_r  #=> (1/1)
'1__234'.to_c  #=> (1+0i)

ENV.clone がエラーになるようになった

Bug #17767: Cloned ENV inconsistently returns ENV or self - Ruby master - Ruby Issue Tracking System

Ruby 3.1 では ENV.dup はエラーになるのに ENV.clone はエラーにならなかったのが、3.2 では ENV.dup と同様にエラーになるようになった。

ENV.dup    #=> Cannot dup ENV, use ENV.to_h to get a copy of ENV as a hash (TypeError)
ENV.clone  #=> Cannot clone ENV, use ENV.to_h to get a copy of ENV as a hash (TypeError)

エラー終了時に例外メッセージをエスケープしないようになった

Feature #18367: Stop the interpreter from escaping error messages - Ruby master - Ruby Issue Tracking System

Ruby 3.1:

% ruby -e 'raise "hoge\0\1\a\b\e\f\n\r\s\t\u0002\v\x03fuga"'
-e:1:in `<main>': hoge\0\x01\a\b\e\f (RuntimeError)
\r  \x02\v\x03fuga

Ruby 3.2:

% ruby -e 'raise "hoge\0\1\a\b\e\f\n\r\s\t\u0002\v\x03fuga"'
-e:1:in `<main>': hoge
                     RuntimeError)
    
        fuga

今までも \n(改行), \s(空白), \t(タブ) はエスケープされずにそのまま表示されていたぽい。

これにより、例外メッセージにエスケープコードを入れて、

% ruby -e 'raise "\e[31m赤いメッセージ\e[0m"'
-e:1:in `<main>': 赤いメッセージ (RuntimeError)   ←「赤いメッセージ」部分は端末上で赤く表示される

みたいなことができるようになった。

今まではエスケープされてたのでこんな感じになってた。

% ruby -e 'raise "\e[31m赤いメッセージ\e[0m"'
-e:1:in `<main>': \e[31m赤いメッセージ\e[0m (RuntimeError)

クラス/モジュール定義時の名前空間の変更

Feature #18832: Do not have class/module keywords consider ancestors of Object - Ruby master - Ruby Issue Tracking System

モジュールを include した状態で、そのモジュール配下に存在するクラスと同名のクラスを class で指定したときに、3.1 まではモジュール配下の既存クラスの変更になっていたが、3.2 では新たなクラスが作られるようになった。

Ruby 3.1:

module Hoge; class Fuga; end; end
include Hoge
p Fuga       #=> Hoge::Fuga
class Fuga
  p self     #=> Hoge::Fuga
end
p Fuga       #=> Hoge::Fuga

Ruby 3.2:

module Hoge; class Fuga; end; end
include Hoge
p Fuga       #=> Hoge::Fuga
class Fuga
  p self     #=> Fuga
end
p Fuga       #=> Fuga

ちなみにトップレベルでなければ 3.1 も 3.2 も動きは変わらない。というか 3.1 のトップレベルだけ動きがおかしかったのが直されたって感じぽい。

module Parent
  module Hoge; class Fuga; end; end
  include Hoge
  p Fuga      #=> Parent::Hoge::Fuga
  class Fuga
    p self    #=> Parent::Fuga
  end
  p Fuga      #=> Parent::Fuga
end

これで Ruby 3.2 アドベントカレンダーは終了。最終日に大盛りだった。

Ruby 3.2 の変更のすべてを調べたわけではないけど、まあだいたいはわかった気になれた。

個人的に面白いと思ったのは IO#timeout とか Regexp.new に文字列でオプション指定可能 とか Integer#ceildiv とか Timeout.timeout がスレッドを浪費しない とかかなー。

ではよいお年を。

Ruby 3.2 - Fiber

Ruby 3.2 アドベントカレンダーの24日目の記事です。

qiita.com


Fiber

Fiber[], Fiber[]=, Fiber#storage 追加

Feature #19078: Introduce Fiber#storage for inheritable fiber-scoped variables. - Ruby master - Ruby Issue Tracking System

Fiber ストレージというのが導入された。Fiber ローカルなストレージだけど、親 Fiber から継承される。子 Fiber で設定したものは親には反映されない。

Fiber[key]=value で値を設定して Fiber[key] で値を取り出せる。ストレージ全体は Fiber#storage で参照できる。

Fiber[:hoge] = 123
p [:p1, Fiber[:hoge]]    #=> 設定された 123
f = Fiber.new do
  p [:c1, Fiber[:hoge]]  #=> 親から継承された 123
  Fiber[:hoge] = 456
  p [:c2, Fiber[:hoge]]  #=> 子で設定した 456
end
f.resume
p [:p2, Fiber[:hoge]]    #=> 親は 123 のまま

p Fiber.current.storage  #=> {:hoge=>123}
p f.storage              #=> {:hoge=>456}

実行結果:

[:p1, 123]
[:c1, 123]
[:c2, 456]
[:p2, 123]
{:hoge=>123}
{:hoge=>456}

Fiber.new(storage: {...}) で Fiber 生成時に初期状態を設定できる。

Fiber.new(storage: {hoge: 123}) do
  Fiber[:hoge]  #=> 123
end

Fiber#storage= で設定もできるが、これは experimental らしい。けど別に warning が出るわけではない。

f = Fiber.new(storage: {hoge: 123}) do
  Fiber[:hoge]  #=> 456
end
f.storage = {hoge: 456}
f.resume

Ruby は Thread#[]= でスレッドローカルに値を保持できる…と思いきや実はこれはスレッドローカルではなくて Fiber ローカルだという罠があって、真にスレッドローカルに値を保持するには Thread#thread_variable_set を使う必要があったりして、ややこしい。

3.2 からは Fiber ローカルなストレージは Fiber[] を使えばいいということになって、すこしはマシになるのかもしれない。

将来的には Thread#[] が真にスレッドローカルなストレージになったりするんだろうか。互換の問題があるからそれはないのかな…。

Ruby 3.2 - OptionParser / Timeout

Ruby 3.2 アドベントカレンダーの23日目の記事です。

qiita.com


default gem のバージョンアップで面白いのがあるかなーと眺めてみた。

OptionParser

OptionParser#raise_unknown

Mode for accepting all unknown options · Issue #38 · ruby/optparse

raise_unknown に偽を指定すると未知のオプションが指定されてもエラーにならないらしい。

--hoge オプションが有効なプログラムを作って、

require 'optparse'
opts = OptionParser.new do |opts|
  opts.on('--hoge'){puts 'hoge option'}
end
opts.parse!(ARGV)

これに --fuga を与えると落ちる。

% ruby hoge.rb --hoge --fuga abc
hoge option
hoge.rb:7:in `<main>': invalid option: --fuga (OptionParser::InvalidOption)

raise_unknown に偽を指定すると落ちない。それはオプションではない普通の引数として扱われるぽい。

require 'optparse'

opts = OptionParser.new do |opts|
  opts.on('--hoge'){puts 'hoge option'}
end
opts.raise_unknown = false
opts.parse!(ARGV)
pp ARGV
% ruby hoge.rb --hoge --fuga abc
hoge option
["--fuga", "abc"]

ただし、普通は通常の引数の後ろにオプションがあってもオプションとして解釈されるんだけど、

% ruby hoge.rb abc --hoge
hoge option
["abc"]

未知のオプションがあった場合はそれ以降に正しいオプションがあってもオプションとはみなされないぽい。

% ruby hoge.rb --fuga --hoge abc
["--fuga", "--hoge", "abc"]

そこで引数の評価を打ち切るって感じなのかな。

Timeout

Timeout.timeout が一つのスレッドで動く

以前は Timeout.timeout は実行されるたびにスレッドがひとつ生成されたので、わりと負荷が高い処理だった。

timeout gem 0.3.1 では、ひとつのスレッドを使いまわすようになったらしい。

こんなスクリプトを実行してみると、

require 'timeout'

def hoge(c)
 if c > 0
   Timeout.timeout(10){hoge(c-1)}
 else
   system("ps -o nlwp -p #$$")
 end
end
hoge(100)

Ruby 3.1 (timeout gem 0.2.0):

% ruby timeout-test.rb
NLWP
 101

Ruby 3.2 (timeout gem 0.3.1):

% ruby timeout-test.rb
NLWP
   2

timeout gem 0.3.1 ではスレッドを消費してないことがわかる。

Ruby 3.2 - default gem の差分 / bundled gem の差分

Ruby 3.2 アドベントカレンダーの22日目の記事です。

qiita.com


default gem の差分

default gem は Ruby に同梱されていて削除できない gem。新しいバージョンの gem のインストールは可能。

default gem のバージョンの Ruby 3.1 との差分を調べてみた。

Gem 3.2.0 3.2.0-rc1 3.1.0
RubyGem 3.4.1 3.4.0.dev 3.3.3
abbrev 0.1.1 0.1.0
base64 0.1.1 0.1.1
benchmark 0.2.1 0.2.1 0.2.0
bigdecimal 3.1.3 3.1.3 3.1.1
bundler 2.4.1 2.4.0.dev 2.3.3
cgi 0.3.6 0.3.6 0.3.1
csv 3.2.6 3.2.2
date 3.3.3 3.3.0 3.2.2
delegate 0.3.0 0.3.0 0.2.0
did_you_mean 1.6.3 1.6.2 1.6.1
digest 3.1.1 3.1.1 3.1.0
drb 2.1.1 2.1.1 2.1.0
english 0.7.2 0.7.1
erb 4.0.2 4.0.2 2.2.3
error_highlight 0.5.1 0.5.1 0.3.0
etc 1.4.2 1.4.1 1.3.0
fcntl 1.0.2 1.0.2 1.0.1
fiddle 1.1.1 1.1.1 1.1.0
fileutils 1.7.0 1.7.0 1.6.0
find 0.1.1 0.1.1
forwardable 1.3.3 1.3.3 1.3.2
getoptlong 0.2.0 0.2.0 0.1.1
io-console 0.6.0 0.5.11 0.5.10
io-nonblock 0.2.0 0.2.0 0.1.0
io-wait 0.3.0 0.3.0.pre 0.2.1
ipaddr 1.2.5 1.2.5 1.2.3
irb 1.6.2 1.5.1 1.4.1
json 2.6.3 2.6.2 2.6.1
logger 1.5.3 1.5.2 1.5.0
mutex_m 0.1.2 0.1.2 0.1.1
net-http 0.3.2 0.3.1 0.2.0
net-protocol 0.2.1 0.2.0 0.1.2
nkf 0.1.2 0.1.2 0.1.1
observer 0.1.1 0.1.1
open-uri 0.3.0 0.3.0 0.2.0
open3 0.1.2 0.1.1
openssl 3.1.0 3.1.0.pre 3.0.0
optparse 0.3.1 0.3.0 0.2.0
ostruct 0.5.5 0.5.5 0.5.2
pathname 0.2.1 0.2.1 0.2.0
pp 0.4.0 0.4.0 0.3.0
prettyprint 0.1.1 0.1.1
pstore 0.1.2 0.1.2 0.1.1
psych 5.0.1 5.0.0 4.0.3
racc 1.6.2 1.6.1 1.6.0
rdoc 6.5.0 6.5.0 6.4.0
readline 0.0.3 0.0.3
readline-ext 0.1.5 0.1.4
reline 0.3.2 0.3.1 0.3.0
resolv 0.2.2 0.2.2 0.2.1
resolv-replace 0.1.1 0.1.0
rinda 0.1.1 0.1.1
ruby2_keywords 0.0.5 0.0.5
securerandom 0.2.2 0.2.1 0.1.1
set 1.0.3 1.0.3 1.0.2
shellwords 0.1.0 0.1.0
singleton 0.1.1 0.1.1
stringio 3.0.4 3.0.3 3.0.1
strscan 3.0.5 3.0.1
syntax_suggest 1.0.2 1.0.1 -
syslog 0.1.1 0.1.0
tempfile 0.1.3 0.1.2
time 0.2.1 0.2.0
timeout 0.3.1 0.3.1 0.2.0
tmpdir 0.1.3 0.1.3 0.1.2
tsort 0.1.1 0.1.1 0.1.0
un 0.2.1 0.2.1 0.2.0
uri 0.12.0 0.12.0 0.11.0
weakref 0.1.2 0.1.1
win32ole 1.8.9 1.8.9 1.8.8
yaml 0.2.1 0.2.0
zlib 3.0.0 3.0.0 2.1.1

太字 はパッチレベルではないバージョンアップ

bundled gem の差分

bundled gem は Ruby に同梱されている gem。普通にアップデートしたり削除したりできる。

bundled gem のバージョンの Ruby 3.1 との差分を調べてみた。

Gem 3.2.0 3.2.0.rc-1 3.1.0
minitest 5.16.3 5.16.3 5.15.0
power_assert 2.0.3 2.0.2 2.0.1
rake 13.0.6 13.0.6
test-unit 3.5.7 3.5.5 3.5.3
rexml 3.2.5 3.2.5
rss 0.2.9 0.2.9
net-ftp 0.2.0 0.2.0 0.1.3
net-imap 0.3.4 0.3.1 0.2.2
net-pop 0.1.2 0.1.2 0.1.1
net-smtp 0.3.3 0.3.3 0.3.1
matrix 0.4.2 0.4.2
prime 0.1.2 0.1.2
rbs 2.8.2 2.8.1 2.0.0
typeprof 0.21.3 0.21.3 0.21.1
debug 1.7.1 1.7.0 1.4.0

太字 はパッチレベルでないバージョンアップ


[2022-12-25 追記] 3.2.0 がリリースされたのでそれも表に追加。

Ruby 3.2 - RubyVM::AbstractSyntaxTree

Ruby 3.2 アドベントカレンダーの21日目の記事です。

qiita.com


RubyVM::AbstractSyntaxTree

RubyVM::AbstractSyntaxTree.parse に error_tolerant オプション追加

Feature #19013: Error Tolerant Parser - Ruby master - Ruby Issue Tracking System

RubyVM::AbstractSyntaxTree.parse とかに構文エラーがあるような文字列を食わせるとエラーになる。

s = <<EOS
def hoge
  p 1,
end
EOS
RubyVM::AbstractSyntaxTree.parse(s)
#=> SyntaxSuggest: Could not find filename from "syntax error, unexpected `end' (SyntaxError)"
#   <internal:ast>:33:in `parse': syntax error, unexpected `end' (SyntaxError)
#           from a.rb:6:in `<main>'

Ruby 3.2 では error_tolerant オプションに真を指定して、エラーにせずにエラーを含んだオブジェクトが返せるようになった。

s = <<EOS
def hoge
  p 1,
end
EOS
RubyVM::AbstractSyntaxTree.parse(s, error_tolerant: true)
#=> (SCOPE@1:0-3:3
#    tbl: []
#    args: nil
#    body:
#      (DEFN@1:0-3:3
#       mid: :hoge
#       body:
#         (SCOPE@1:0-3:3
#          tbl: []
#          args:
#            (ARGS@1:8-1:8
#             pre_num: 0
#             pre_init: nil
#             opt: nil
#             first_post: nil
#             post_num: 0
#             post_init: nil
#             rest: nil
#             kw: nil
#             kwrest: nil
#             block: nil)
#          body: (ERROR@2:2-3:3))))

不完全な状態をパースするようなツールとかにはいいのかもしれない。しらんけど。

RubyVM::AbstractSyntaxTree.parse に keep_tokens オプション追加

Feature #19070: Enhance keep_tokens option for RubyVM::AbstractSyntaxTree parsing methods - Ruby master - Ruby Issue Tracking System

keep_tokens に真を指定すると #tokens でトークンを得ることができるようになった。

s = <<EOS
def hoge
  p 1,2,3
end
EOS
ast = RubyVM::AbstractSyntaxTree.parse(s, keep_tokens: true)

pp ast.tokens
# [[0, :keyword_def, "def", [1, 0, 1, 3]],
#  [1, :tSP, " ", [1, 3, 1, 4]],
#  [2, :tIDENTIFIER, "hoge", [1, 4, 1, 8]],
#  [3, :nl, "\n", [1, 8, 1, 9]],
#  [4, :tSP, "  ", [2, 0, 2, 2]],
#  [5, :tIDENTIFIER, "p", [2, 2, 2, 3]],
#  [6, :tSP, " ", [2, 3, 2, 4]],
#  [7, :tINTEGER, "1", [2, 4, 2, 5]],
#  [8, :",", ",", [2, 5, 2, 6]],
#  [9, :tINTEGER, "2", [2, 6, 2, 7]],
#  [10, :",", ",", [2, 7, 2, 8]],
#  [11, :tINTEGER, "3", [2, 8, 2, 9]],
#  [12, :nl, "\n", [2, 9, 2, 10]],
#  [13, :keyword_end, "end", [3, 0, 3, 3]]]

puts ast.tokens.map{_1[2]}.join
# def hoge
#   p 1,2,3
# end