vm2gol v2 製作メモ(4) カウンタ / プログラムをファイルに



コンピュータのようなものを作っているので、 何か compute させようということで、足し算をさせて、 regチカのときのループと組み合わせてカウンタを作ってみます。 reg_a の値を 1, 2, 3, ... と増やしていくというものです。

add_ab() というメソッドがすでにあり、 足し算した結果を reg_c に入れるようにしてましたが、 ちょっと書き換えて、結果を reg_a に入れることにします。

(こんな感じで、まず適当に作って、 後で調べたり考えなおしたりして適当に修正していきます。)

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -62,7 +62,7 @@ class Vm
   end
 
   def add_ab
-    @reg_c = @reg_a + @reg_b
+    @reg_a = @reg_a + @reg_b
   end
 end

VM のメインループから呼び出す部分がなかったので書いておきます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -32,6 +32,9 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "add_ab"
+        add_ab()
+        @pc += 1
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr

ループは前回の regチカと同じですね。 jump を使うだけです。

BASIC 風に書くとこんな感じ。

10 set_reg_a 1
20 set_reg_b 1
30 add_ab
40 jump 30

このようにプログラムを書き換えましょう。 簡単なので BASIC 風に書くまでもなかったかも。

    @mem = [
      # 0
      "set_reg_a", 1,
      # 2
      "set_reg_b", 1,
      # 4
      "add_ab",
      # 5
      "jump", 4
    ]

動かしてみると set_reg_b なんて命令はないぞ、 と怒られるので適当に set_reg_b 命令を追加します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -35,6 +35,10 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "set_reg_b"
+        n = @mem[@pc + 1]
+        @reg_b = n
+        @pc += 2
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr

あらためて実行すると……

$ ruby vgvm.rb 
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=1,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=2,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=2,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=3,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=3,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=4,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=4,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=5,
 @reg_b=1,
 @reg_c=0>
(略)

いいですね! プログラムカウンタが 4 と 5 を行ったり来たりして、reg_a の値が1ずつ増えています。

(ずっと動き続けるので、飽きたら Ctrl-C で止めましょう)

プログラムを外部に切り出す

さて、ここまではプログラムをこのようにメモリに直書きしていましたが、

    @mem = [
      # 0
      "set_reg_a", 1,
      # 2
      "set_reg_b", 1,
      # ...
    ]

そうするとプログラムの切り替えが面倒なので ファイルに切り出すことにします。

最初は Ruby のソースとしてそのままファイルに出して eval を使ってこうしていたのですが、

# プログラムファイル
[
  # 0
  "set_reg_a", 1,
  # 2
  "set_reg_b", 1,
  # ...
]

# プログラムファイルの読み込み
@mem = eval(File.read("program.rb"))

いろいろあって YAML にしたのでここではいきなりそのようにします (eval 方式は、簡単だし pppシリアライズできるのでこれはこれで悪くはなかったのですが)。

プログラムのファイルの内容はこう。 vm2gol 用の実行可能(executable)ファイルということで、拡張子を .vge.yaml としました。

# 04_counter.vge.yaml
[
  # 0
  "set_reg_a", 1,
  # 2
  "set_reg_b", 1,
  # 4
  "add_ab",
  # 5
  "jump", 4
]

ファイルからロードするように VM を修正します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -1,5 +1,6 @@
 # coding: utf-8
 require 'pp'
+require 'yaml'
 
 class Vm
   def initialize
@@ -11,16 +12,11 @@ class Vm
     @reg_b = 0
     @reg_c = 0
 
-    @mem = [
-      # 0
-      "set_reg_a", 1,
-      # 2
-      "set_reg_b", 1,
-      # 4
-      "add_ab",
-      # 5
-      "jump", 4
-    ]
+    @mem = []
+  end
+
+  def load_program(path)
+    @mem = YAML.load_file(path)
   end
 
   def start
@@ -76,7 +72,10 @@ class Vm
   end
 end
 
+exe_file = ARGV[0]
+
 vm = Vm.new
+vm.load_program(exe_file)
 pp vm # 初期状態
 
 vm.start

実行するときは、プログラムファイルを引数で渡してこうします。

ruby vm.rb 04_counter.vge.yaml

引数でのファイルの指定を変えるだけで実行するプログラムが切り替えられるようになりました。 進歩!



vm2gol v2 製作メモ(3) regチカ(jump)



プログラムといえばループ! ですね(※1)。 ループをやりましょうということで、 jump 命令を追加して regチカをやりましょう。

(※1 ……と書いてはいますが、実際は「次に簡単そうなのは何かな?」と考えて 何をやるのかを決めていました。 スモールステップでやっていきます。 )

regチカ(れぐちか)というのは今勝手に作った言葉で、 電子工作の Lチカのように、 レジスタの値を 0 にしたり 1 にしたりして チカチカするのを愛でるというものです。

BASIC風に書くとこんな感じでしょうか。

10 reg_a に 1 をセット
20 reg_a に 0 をセット
30 GOTO 10

これならすぐできそう。VM の case 式に jump を追加して、

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -30,6 +30,9 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "jump"
+        addr = @mem[@pc + 1]
+        @pc = addr
       else
         raise "Unknown operator (#{op})"
       end

プログラム(@mem の中身)を書き換えます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -12,8 +12,10 @@ class Vm
     @reg_c = 0
 
     @mem = [
+      # ★1
       "set_reg_a", 1,
       "set_reg_a", 0,
+      "jump", 0, # ★1 に戻る
       "exit"
     ]
   end

一番最初の set_reg_a のアドレスが 0 なので、 0 にジャンプします。

これを動かすと……

$ ruby vgvm.rb 
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=4,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=4,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
(続く)

@reg_a=1 になったり @reg_a=0 になったりしてますね! jump 命令で1ステップ使うので3拍子になっていますが。

ものすごく原始的なプログラムでものすごく原始的なループが動きました。 すばらしい……(しばしターミナルを眺めて愛でる)。



vm2gol v2 製作メモ(2) プログラムカウンタとVMの骨組み



第一回はさすがにあっさりすぎたのでどんどんやっていきます。

今の状態では vm = Vm.new した後に vm に対して外部から指示してあれこれやらせていて、 あんまり VM というかコンピュータっぽくない(最初に起動した後は勝手に動いてほしい)ので、それっぽくしていきます。

まずは命令をメモリに置いて、それを順次処理していくようにします。 そのためにプログラムカウンタも導入します。


まずはプログラムをメモリに直に書くようにして

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -8,7 +8,11 @@ class Vm
     @reg_b = 0
     @reg_c = 0
 
-    @mem = Array.new(8, 0) # サイズ8。0で初期化。
+    @mem = [
+      "set_reg_a", 1,
+      "set_reg_a", 0
+    ]
+  end
   end
 
   def set_mem(addr, n)
@@ -35,14 +39,3 @@ end
 vm = Vm.new
 pp vm # 初期状態
 
-vm.set_mem(0, 1)
-vm.set_mem(1, 2)
-pp vm
-
-vm.copy_mem_to_reg_a(0)
-vm.copy_mem_to_reg_b(1)
-pp vm
-
-vm.add_ab
-vm.copy_reg_c_to_mem(2)
-pp vm

@pcVm#start を追加:

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -3,6 +3,9 @@ require 'pp'
 
 class Vm
   def initialize
+    # program counter
+    @pc = 0
+
     # register
     @reg_a = 0
     @reg_b = 0
@@ -13,6 +16,24 @@ class Vm
       "set_reg_a", 0
     ]
   end
+
+  def start
+    loop do
+      # operator
+      op = @mem[@pc]
+      case op
+      when "set_reg_a"
+        n = @mem[@pc + 1]
+        @reg_a = n
+        @pc += 2
+      else
+        raise "Unknown operator (#{op})"
+      end
+
+      # 1命令実行するごとにダンプしてちょっと待つ
+      pp self
+      sleep 1
+    end
   end
 
   def set_mem(addr, n)
@@ -39,3 +60,4 @@ end
 vm = Vm.new
 pp vm # 初期状態
 
+vm.start

メモリ上では「"set_reg_a"」と「1」の2つなので、 set_reg_a した後はプログラムカウンタを2つ進めます。

これを動かすと……

$ ruby vgvm.rb 
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=2,
 @reg_a=1, … ★1
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=4,
 @reg_a=0, … ★2
 @reg_b=0,
 @reg_c=0>
vgvm.rb:30:in `block in start': Unknown operator () (RuntimeError)
        from vgvm.rb:21:in `loop'
        from vgvm.rb:21:in `start'
        from vgvm.rb:67:in `<main>'

reg_a が ★1 で 1 に, ★2 で 0 になっているのが確認できますね!  プログラムカウンタも2つずつ進んでいます。


これで、

  • プログラムカウンタが指している場所から命令と引数を取ってくる
  • 命令を実行する
  • プログラムカウンタを進める
  • (くりかえし)

という VM の骨組みができました。 実装としては case による条件分岐をループで回す、という形になっていて、後はこれに肉付けしていくことになります。


最後に例外が発生して終了するのはプログラム通りの動きではありますが、 exit という命令を追加して、例外を発生させずに静かに終了するようにしておきます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -13,7 +13,8 @@ class Vm
 
     @mem = [
       "set_reg_a", 1,
-      "set_reg_a", 0
+      "set_reg_a", 0,
+      "exit"
     ]
   end
 
@@ -22,6 +23,9 @@ class Vm
       # operator
       op = @mem[@pc]
       case op
+      when "exit"
+        $stderr.puts "exit"
+        exit
       when "set_reg_a"
         n = @mem[@pc + 1]
         set_reg_a(n)


vm2gol v2 製作メモ(1) 最初のVM



とにかく始める

とにかくやるのだぁ

(『30日でできる! OS自作入門』 p14)


とりあえずアレですよね? レジスタとメモリがあるんですよね? などといって適当に書き始めます。 慣れていてサッと始められるので Ruby です。 サッと始められるの大事です。

# coding: utf-8
require "pp"

class Vm
  def initialize
    # register
    @reg_a = 0
    @reg_b = 0
    @reg_c = 0

    @mem = Array.new(8, 0) # サイズ8、0で初期化
  end

  def set_mem(addr, n)
    @mem[addr] = n
  end

  def copy_mem_to_reg_a(addr)
    @reg_a = @mem[addr]
  end

  def copy_mem_to_reg_b(addr)
    @reg_b = @mem[addr]
  end

  def copy_reg_c_to_mem(addr)
    @mem[addr] = @reg_c
  end

  def add_ab
    @reg_c = @reg_a + @reg_b
  end
end

vm = Vm.new
pp vm # 初期状態

vm.set_mem(0, 1)
vm.set_mem(1, 2)
pp vm # ★1

vm.copy_mem_to_reg_a(0)
vm.copy_mem_to_reg_b(1)
pp vm # ★2

vm.add_ab
vm.copy_reg_c_to_mem(2)
pp vm # ★3

★1 なにはともあれメモリに値をセットする。 0番地に 1 が、1番地に 2 が入っていればOK。

★2 メモリの値をレジスタにコピーしてみる。 reg_a が 1 に、reg_b が 2 になっていればOK。

★3 足し算して結果を reg_c に入れて、それをメモリにコピーする、というのをやってみる。 reg_c とメモリの 2番地に 3 が入っていればOK。

$ ruby vgvm.rb
#<Vm:0x005650ed9eaa40
     @mem=[0, 0, 0, 0, 0, 0, 0, 0],
     @reg_a=0,
     @reg_b=0,
     @reg_c=0> ... 初期状態
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 0, 0, 0, 0, 0, 0],
     @reg_a=0,
     @reg_b=0,
     @reg_c=0> ... ★1
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 0, 0, 0, 0, 0, 0],
     @reg_a=1,
     @reg_b=2,
     @reg_c=0> ... ★2
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 3, 0, 0, 0, 0, 0],
     @reg_a=1,
     @reg_b=2,
     @reg_c=3> ... ★3

はい。

ちょっとあっさりしすぎですがこんな感じで始めました。 最初なのでこんなもんで。

余談

足し算のところは、下記のようにして桁あふれのエミュレーションもやってましたが 最終的に使わなくなったので消しました。

sum = @reg_a + @reg_b
if sum >= 16
  @reg_c = sum - 16
  @of = 1 # オーバーフローフラグ
else
  @reg_c = sum
end

雑に作る

(2024-01-13 追記)

雑に作る』という本に大変すばらしいことが書かれていたので引用します。

「雑の極意」

一、気軽に作り始めること
一、完成度は低くてもまずは完成させること
一、見た目にこだわらないこと
一、1つの傑作より10の駄作を作ること
一、広く深く学ぶより、いま必要なことを学ぶこと
一、1つの技術で10作品作ること
一、「雑」をよいことととらえること

人はついつい、作品を作る前に「傑作を作るぞ!」と意気込んでしまいがちだ。そして実際に作ってみると想像していたようにいかず、途中で飽きて投げ出してしまう。工作あるあるのなかでもかなり上位にランクインするあるあるだろう。

そこで「雑に作る」の精神だ。長い期間かけて立派な作品を作るのではなく、3日で雑な小品を1つ作る。気軽に始めて、飽きる前に終わらせるのだ。そうすればあなたの作品集に1つ作品が増える。そしてそんな雑な作品をどんどん作り続けるうちに、いつのまにかあなたはいろんな電子部品の使い方を覚え、複雑な機構も組めるようになり、大作を作り上げるだけの実力を手に入れていることだろう!

まさにこれです。この精神でやっていきます。



vm2gol v2 製作メモ 目次2


ちまちまいじって経緯をメモしていますが、とりあえず以下は無視して最新のスナップショットmain ブランチ)のソースを見てもらうのがいいんじゃないかなと思います。

簡単な自作言語のコンパイラ vm2gol v2 の製作過程

RubyでオレオレVMとアセンブラとコード生成器を2週間で作ってライフゲームを動かした話 の製作過程を雑にダラダラ書きます。

  • 下書きっぽいものは用意しているので、打ち切りにならずに完走はできると思います(2019-10-05 完走しました!)
    • 予定では全38回
    • 時間や気力不足などで 1ヶ月更新が止まるとかはあるかもしれません
  • 教科書的な正しさは保証できませんし、自分用のふりかえりメモみたいな体でやります
    • なので、文体は丁寧でも説明は全然足りてないと思います
  • 最初に作ったもの(v1)と全く同じではなく、微妙に違うものになります
    • 試行錯誤した過程を全部そのまま書くとさすがに退屈・煩雑になるので多少整理したり
      • あと、作って時間が経ってるので細かいところは覚えてない
    • あまり大きく変えない程度に改良したり(2周目なので)
  • 私はあれこれ悩みながら2週間かけて作りましたが、 単純に書き写すだけなら1週間もかからないんじゃないかと思います
    • なので、「コンパイラ作ってみたいけど何ヶ月もかかるような本格的なのはちょっと無理そう……」 と思っていたり、実際やってみて挫折した、というような昔の自分みたいな人の役に立てたらいいなあ、という気持ちがあります

リポジトリ

github.com

目次

VMアセンブラは飛ばしてコンパイラっぽい部分から見たいという場合は「16. 簡単なコード生成」あたりから見てください。

VM

アセンブラ

コード生成

このあたりで最低限必要なものは大体出揃っていて、以降は肉付けやバグ修正をしながらライフゲームを書いていく作業になります。

ライフゲームの実装開始

ここまでできた時点で RubyでオレオレVMとアセンブラとコード生成器を2週間で作ってライフゲームを動かした話 を書きました。

ライフゲームが動いた後の修正

以下は完成後の追加の修正。おまけコーナーです。

さらにその後

ここから先はちびちびとリファクタリングしたりフォーマットをいじってみたり。増えてきたのでページを分けました。

Plumo: pure Ruby + WEBrick + Comet + Canvas で手軽にお絵描き

f:id:sonota88:20190430115342p:plain

自分が使うのに必要なものを最低限雑に実装してパッと出した、という感じのものです。API 固まってないです。

sonota88/plumo
https://github.com/sonota88/plumo

コンセプト的なもの

  • (動きのある)2Dグラフィックを手軽にやりたくなる時がある
    • 何か思いついたときにサッとやりたいので、pure ruby
      • インストールとかでトラブらないように(なるべく yak shaving したくない)
  • トラブルなくサッと使えるというのが最優先で、パフォーマンス、機能はそこそこで良い
    • ちゃんとやりたくなったら他のちゃんとしたものを使いましょう。 こいつはてきとーです。
  • ちょっとしたプログラムにちょっとしたグラフィック機能を付けたいことがある
    • そういう場合に手軽に組み込んで使いたい

使い方

gem install plumo
# sample.rb

require 'plumo'

# 幅、高さ(ピクセル)を指定して new
plumo = Plumo.new(200, 200)

# サーバ起動
plumo.start

# 描画色を指定
plumo.color "#0f0"

loop do
  # 始点と終点を指定して線を引く
  plumo.line 0, 0, rand * 200, rand * 200

  sleep 1
end
# 実行
ruby sample.rb

# ブラウザで http://localhost:9080/ を開く
# Ctrl-C で終了

何か挙動がおかしいとかあったらブラウザをリロードしたり、 プログラムを止めて実行し直したりしてください(適当)。

Canvas の命令を直接実行する

Plumo#draw を使うと直接 Canvas の命令を実行できます。

plumo.draw(
  [:strokeStyle, "#f00"],
  [:fillStyle, "#ff0"],
  [:fillRect, x, y, w, h]
)

これを使えば何でもできる。命令と引数はそのまま渡すだけなので詳しくは Canvas のリファレンスを参照(丸投げ)。

上のサンプルの Plumo#line とかはこれのラッパーです。

しくみ

Plumo を使ったプログラム
 |
Plumo の API
 |
Thread::SizedQueue
 |
WEBrick::HttpServer
 |
 | Comet
 |
Web browser

サンプル

リポジトリの examples/ 以下にいくつかサンプルを入れています。 git clone して次のように実行してみてください:

ruby examples/game_of_life.rb

今のところ(v0.0.4 では)他の gem に依存していないので、この方法で試すだけなら gem install や bundle install は不要です。

あと、一番上に貼ってある画像は examples/random.rb で描いたものです。

もっとお手軽な機械可読テキストテーブルフォーマット

前回の Mrtable の紹介エントリ を書いてたときにもっとお手軽なものを思いつき、「あれっ、桁を揃えるだけならひょっとしてこんなので良かった?」と思ってしまったのでメモ。

こんなの:

[ "c1" , "c2"           , "c3"  , "c4"                   ]
[    1 , "a"            , ""    , null                   ]
[ " "  , "null"         , 12.34 , "\\\t\r\n\"\\\t\r\n\"" ]
[ 1234 , "全角テキスト" , ""    , ""                     ]

要するに1行が1個の配列で、それを JSON にして、桁を揃えるだけ。頻繁に手書きするのでなければこれで十分では。

パースは行ごとに JSON.parse() すればいい(実際これだけなのでライブラリ化する必要もないくらい)として、桁揃えも Mrtable のを流用すればすぐできそう、ということでちゃちゃっと書いてみました。

require 'json'

module JsonArrayTable

  def self.parse(text)
    text.lines.map{ |line| JSON.parse(line) }
  end

  def self.generate(rows)
    num_cols = rows[0].size

    serialized =
      map_col_with_ci(rows) do |col, _|
        col.to_json
      end

    max_lens =
      (0...num_cols).map do |ci|
        col_len_max(serialized, ci)
      end

    padded =
      map_col_with_ci(serialized) do |col, ci|
        pad_col(col, max_lens[ci])
      end

    lines =
      padded.map do |cols|
        "[ " + cols.join(" , ") + " ]\n"
      end

    lines.join("")
  end

  private

  def self.map_col_with_ci(rows)
    rows.map do |cols|
      indexes = (0...cols.size).to_a
      cols.zip(indexes).map do |col_ci|
        yield *col_ci
      end
    end
  end

  def self.col_len_max(rows, ci)
    rows
      .map{ |cols| col_len(cols[ci]) }
      .max
  end

  # 32-126(0x20-0x7E), 65377-65439(0xFF61-0xFF9F)
  def self.hankaku?(c)
    /^[ -~。-゚]$/.match?(c)
  end

  def self.col_len(col)
    col.chars
      .map{ |ci| hankaku?(col[ci]) ? 1 : 2 }
      .sum
  end

  def self.pad_right(s, n)
    rest = n - col_len(s)
    return s if rest == 0
    s + (" " * rest)
  end

  def self.pad_left(s, n)
    rest = n - col_len(s)
    return s if rest == 0
    (" " * rest) + s
  end

  def self.int?(s)
    /^\-?[\d]+$/.match?(s)
  end

  def self.pad_col(col, maxlen)
    if int?(col)
      pad_left(col, maxlen)
    else
      pad_right(col, maxlen)
    end
  end

end

例:

require 'pp'

text = <<-'EOB'
["c1", "c2", "c3", "c4"]
[1, "a", "", null]
[" ", "null", 12.34, "\\\t\r\n\"\\\t\r\n\""]
[1234, "全角テキスト", "", ""]
EOB

rows = JsonArrayTable.parse(text)
pp rows

=begin

[["c1", "c2", "c3", "c4"],
 [1, "a", "", nil],
 [" ", "null", 12.34, "\\\t\r\n" + "\"\\\t\r\n" + "\""],
 [1234, "全角テキスト", "", ""]]

=end

puts JsonArrayTable.generate(rows)

=begin

[ "c1" , "c2"           , "c3"  , "c4"                   ]
[    1 , "a"            , ""    , null                   ]
[ " "  , "null"         , 12.34 , "\\\t\r\n\"\\\t\r\n\"" ]
[ 1234 , "全角テキスト" , ""    , ""                     ]

=end

適当に JsonArrayTable という名前にしましたが、もうこれでいいかな……命名のセンスなくて悲しみ。 JSON を抜かして array table とか…… 誰かかっこいい名前を考えてください……。

(2021-04-18 追記) array table でいいかな……という気分になってきています。

Mrtable: 機械可読なテキストテーブルフォーマット

概要

  • 機械可読でプレーンテキストなテーブルのフォーマット
    • mr = machine readable
    • machine readable かつ human readable(というのが欲しくて考えました)

簡単な説明

  • ベースは GFM(github flavored markdown)のテーブル
  • 各セルの内容は JSON の文字列としてパースできるように書く
    • 数や日時などもすべて文字列として扱う
    • ※ パイプ |エスケープのみ例外。後述。
| "id" | "name" | "value"      |
| ---- | ------ | ------------ |
|  "1" | "foo"  | "1\t\r\n2"   |
|  "2" | "bar"  | "1\\\"2"     |
|  "3" | "baz"  | "2019-04-13" |
|  "4" | "qux"  | "12.34"      |

  • 何もない(半角スペースのみの)セルは null
| "col1" |
| ------ |
|        |

  • パイプ | はバックスラッシュでエスケープする
    • 列の区切りと区別するため
    • しなくていい場合もありますが、よく分からなかったら「常にエスケープする」で問題ありません
| "col1" |
| ------ |
| " \| " |

基本的にはこんな感じ(最低限これだけ知ってれば書くことはできる)ですが、もうちょっといろいろあります。

もうすこし詳しい説明

  • 情報が失われない場合は両端のダブルクォートは省略可
    • 見た目の煩雑さ、手入力の際の手間を減らすため
| id  | name | value |
| --- | ---- | ----- |
|   1 | foo  | 1\n2  |

  • 情報が失われる場合は省略できない
    • 空文字
    • 半角スペースのみ
    • 文字列の左端 and/or 右端に1個以上のスペースがある
| col1 | col2  | col3  |
| ---- | ----- | ----- |
| ""   | " "   | "  "  |
| " a" | "a  " | " a " |

  • パイプ(|
    • パイプ1文字の両側に半角スペースがある場合は(ダブルクォートで囲むかどうかに関わらず)バックスラッシュエスケープ必須
| col1   | col2   |
| ------ | ------ |
| 1 \| 2 | " \| " |

実装

sonota88/mrtable

  • Ruby 用のパーサ・シリアライザ
  • エラー処理などは適当
  • v0.0.4 時点では 252 行
# v0.0.4

require 'pp'
require 'mrtable'

mrtable_text = <<'EOB'
| c1  | c2  | c3  | c4  |
| --- | --- | --- | --- |
| "123" | "abc" | "日本語" |    |
|  123  |  abc  |  日本語  |    |
| "" | " " | "  " |  |
| "\\\t\r\n\"" |  |  |  |
|  \\\t\r\n\"  |  |  |  |
| "a" | " a" | "a " | " a " |
|  a  |   a  |  a   |   a   |
| "\|" | "1 \| 2" |  |  |
|  \|  |  1 \| 2  |  |  |
| "null" | "NULL" |  |  |
|  null  |  NULL  |  |  |
EOB

header, rows = Mrtable.parse(mrtable_text)
pp header, rows

=begin

["c1", "c2", "c3", "c4"]
[["123", "abc", "日本語", nil],
 ["123", "abc", "日本語", nil],
 ["", " ", "  ", nil],
 ["\\\t\r\n" + "\"", nil, nil, nil],
 ["\\\t\r\n" + "\"", nil, nil, nil],
 ["a", " a", "a ", " a "],
 ["a", "a", "a", "a"],
 ["|", "1 | 2", nil, nil],
 ["|", "1 | 2", nil, nil],
 ["null", "NULL", nil, nil],
 ["null", "NULL", nil, nil]]

=end

puts Mrtable.generate(header, rows)

=begin

| c1         | c2     | c3     | c4    |
| ---------- | ------ | ------ | ----- |
|        123 | abc    | 日本語 |       |
|        123 | abc    | 日本語 |       |
| ""         | " "    | "  "   |       |
| \\\t\r\n\" |        |        |       |
| \\\t\r\n\" |        |        |       |
| a          | " a"   | "a "   | " a " |
| a          | a      | a      | a     |
| \|         | 1 \| 2 |        |       |
| \|         | 1 \| 2 |        |       |
| null       | NULL   |        |       |
| null       | NULL   |        |       |

=end

その他