tag:crieit.net,2005:https://crieit.net/tags/Reline/feed
「Reline」の記事 - Crieit
Crieitでタグ「Reline」に投稿された最近の記事
2021-05-04T02:23:29+09:00
https://crieit.net/tags/Reline/feed
tag:crieit.net,2005:PublicArticle/17054
2021-05-04T02:12:42+09:00
2021-05-04T02:23:29+09:00
https://crieit.net/posts/ruby-reline-readmultiline-mysql-wrapper
Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる
<p><code>Reline.readmultiline</code> の基本的な使い方を<a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/20210417_reline_readmultiline">前回調べました</a>ので、練習で何か作ってみようかと。</p>
<p>何でもよかったのですが、ためしに mysql コマンド(MySQLクライアント)のラッパーを作ってみました。mysql コマンドの対話的インターフェイス、複数行入力はできますが、履歴を遡ると1行にされてちょっと不便ですよね(という体でやってみました)。</p>
<h1 id="使い方"><a href="#%E4%BD%BF%E3%81%84%E6%96%B9">使い方</a></h1>
<p>先にテスト用の MySQL サーバを Docker で起動しておいて <code>mysql_wrapper.rb</code> を実行します。</p>
<pre><code class="sh">$ docker run -d --rm --name mysql_test \
-e MYSQL_ALLOW_EMPTY_PASSWORD=yes \
mysql:8.0.23
# サーバが起動するのを待ってから
$ ruby mysql_wrapper.rb
</code></pre>
<h1 id="mysql_wrapper.rb"><a href="#mysql_wrapper.rb">mysql_wrapper.rb</a></h1>
<pre><code class="ruby">require "json"
require "shellwords"
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "reline", "0.2.5" # 最新バージョンで試すため
end
HISTORY_FILE = "mysql_history"
PROMPT = "*mysql> "
def add_history(text)
File.open(HISTORY_FILE, "a") { |f| f.puts JSON.generate(text) }
end
def load_history
return unless File.exist?(HISTORY_FILE)
File.read(HISTORY_FILE).each_line do |json|
Reline::HISTORY << JSON.parse(json)
end
end
def execute_sql(sql)
cmd = Shellwords.shelljoin([
"docker", "exec", "-it", "mysql_test",
"/usr/bin/mysql", "-e", sql
])
output = `#{cmd}`
unless $?.success?
puts output
return
end
if output.empty? # use, create table などの場合
puts "(empty output)"
puts "----"
return
end
puts output
end
Reline.prompt_proc =
Proc.new do |lines|
lines.each_with_index.map do |line, i|
i == 0 ? PROMPT : "* -> "
end
end
load_history
loop do
text =
Reline.readmultiline(PROMPT, true) do |input|
input.strip.end_with?(";")
end
add_history text
if text.start_with?("exit")
puts "bye"
break
end
execute_sql text
end
</code></pre>
<h1 id="実行の例"><a href="#%E5%AE%9F%E8%A1%8C%E3%81%AE%E4%BE%8B">実行の例</a></h1>
<pre><code>$ ruby mysql_wrapper.rb
*mysql> create database db1;
(empty output)
----
*mysql> create table db1.t1 (
* -> c1 int
* -> , c2 text
* -> , c3 text
* -> );
(empty output)
----
*mysql> insert into db1.t1 values
* -> ( 1, 'null' , 'NULL')
* -> , ( 12, '' , ' ')
* -> , ( 123, '寿司ビール🍣🍺', ' x ')
* -> , (1234, 'xml[&<>"] sq[''] bs[\\] t[\t] n[\n] r[\r]', null)
* -> ;
(empty output)
----
*mysql> select * from db1.t1;
+------+--------------------------------------+------+
| c1 | c2 | c3 |
+------+--------------------------------------+------+
| 1 | null | NULL |
| 12 | | |
| 123 | 寿司ビール🍣🍺 | x |
| 1234 | xml[&<>"] sq['] bs[\] t[ ] n[
] | NULL |
+------+--------------------------------------+------+
*mysql> exit;
bye
$
</code></pre>
<p>履歴を遡ってもちゃんと複数行のまま復元されます。</p>
<p>1回ごとに mysql コマンドを実行しているので、 use 文などで状態を変えても毎回リセットされる点に注意。mysql プロセスとパイプでやりとりするというのも一瞬考えましたが、サンプルにしては大げさになるかなと思ってやめました。</p>
<p>「末尾が <code>;</code> になっていること」を編集完了の条件にしているので、 <code>exit</code> ではなく <code>exit;</code> のように末尾にセミコロンを付ける必要があります。</p>
<p>今回のこれはお遊び程度のものですが、入力されたクエリをどこかのサーバに送って記録するとか、MySQL に渡す前にチェックして drop や delete などの文の実行を禁止するとか、いろいろ応用できそうな気がします。</p>
<h1 id="おまけ"><a href="#%E3%81%8A%E3%81%BE%E3%81%91">おまけ</a></h1>
<pre><code>+------+--------------------------------------+------+
| c1 | c2 | c3 |
+------+--------------------------------------+------+
| 1 | null | NULL |
| 12 | | |
| 123 | 寿司ビール🍣🍺 | x |
| 1234 | xml[&<>"] sq['] bs[\] t[ ] n[
] | NULL |
+------+--------------------------------------+------+
</code></pre>
<p>この MySQL の標準の表示ももうちょっといい感じになってほしいんですよね……。</p>
<ul>
<li>文字列の <code>NULL</code> と null が区別できない</li>
<li>空文字なのか半角スペースなのか分からない</li>
<li>半角じゃない文字があるとずれる</li>
<li>改行があると表が崩れる</li>
<li>上の例だと CR の前の部分が見えない</li>
</ul>
<p>というわけで、結果をパースしていい感じにしてみます。</p>
<p>mysql コマンドのオプションに <code>--xml</code> を付けて、出力された XML をパースして pp で表示。<br />
CR, LF の変換は今回の例をうまく動かすための適当なものです。適切な対処方ではない気がします。</p>
<pre><code class="ruby">require "rexml/document"
def parse_mysql_xml(xml)
doc = REXML::Document.new(
xml
.gsub("\n", "
")
.gsub("\r", "
")
)
row_els = REXML::XPath.match(doc, "resultset/row")
colnames = row_els[0].elements.map { |field_el| field_el["name"] }
body_rows =
row_els.map do |row_el|
row_el.elements.map do |field_el|
if field_el["xsi:nil"] == "true"
nil
else
if field_el.text.nil?
""
else
field_el.text.gsub("\r\n", "\n")
end
end
end
end
[colnames, *body_rows]
end
# ...
rows = parse_mysql_xml(output)
pp rows
</code></pre>
<pre><code class="ruby"># 出力:
[["c1", "c2", "c3"],
["1", "null", "NULL"],
["12", "", " "],
["123", "寿司ビール🍣🍺", " x "],
["1234", "xml[&<>\"] sq['] bs[\\] t[\t] n[\n" + "] r[\r]", nil]]
</code></pre>
<hr />
<p>さらにおまけとして、以前書いた <a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/2019/04/20/101713">これ</a> を使って列を揃えてみました。</p>
<pre><code class="ruby"> require_relative "json_array_table"
# ...
rows = parse_mysql_xml(output)
puts JsonArrayTable.generate(rows)
</code></pre>
<pre><code class="javascript">// 出力:
[ "c1" , "c2" , "c3" ]
[ "1" , "null" , "NULL" ]
[ "12" , "" , " " ]
[ "123" , "寿司ビール🍣🍺" , " x " ]
[ "1234" , "xml[&<>\"] sq['] bs[\\] t[\t] n[\n] r[\r]" , null ]
</code></pre>
<p>割と満足。</p>
<h1 id="Ruby関連で他に書いたもの"><a href="#Ruby%E9%96%A2%E9%80%A3%E3%81%A7%E4%BB%96%E3%81%AB%E6%9B%B8%E3%81%84%E3%81%9F%E3%82%82%E3%81%AE">Ruby関連で他に書いたもの</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/archive/category/Ruby">https://memo88.hatenablog.com/archive/category/Ruby</a></p>
sonota486
tag:crieit.net,2005:PublicArticle/17017
2021-04-28T08:43:35+09:00
2021-04-28T08:46:47+09:00
https://crieit.net/posts/ruby-reline-readmultiline
Reline.readmultiline ちょっと調べたメモ
<p><a target="_blank" rel="nofollow noopener" href="https://github.com/ruby/reline">Reline</a> を使うと複数行編集ができるようなので、自分が使いそうな基本的な部分について調べてみました。</p>
<p>このリッチなのが標準で使えるの嬉しいですよね。ありがたや……。</p>
<pre><code>RUBY_VERSION #=> 3.0.0
Reline::VERSION #=> 0.2.5
</code></pre>
<h1 id="最初の雛形"><a href="#%E6%9C%80%E5%88%9D%E3%81%AE%E9%9B%9B%E5%BD%A2">最初の雛形</a></h1>
<p>ブロックは必須。</p>
<pre><code class="ruby">require "reline"
PROMPT = "> "
loop do
text =
Reline.readmultiline(PROMPT) do |_|
# このブロックについては後述
true
end
# 編集が完了した複数行文字列を使った処理
puts "text (#{text})"
end
</code></pre>
<p>これだけだとまだ複数行編集できないんですが、デバッグ用のログと履歴まわりを準備しておくと捗るので先にそっちを片付けます。</p>
<h1 id="挙動確認のためのデバッグログ出力"><a href="#%E6%8C%99%E5%8B%95%E7%A2%BA%E8%AA%8D%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%83%87%E3%83%90%E3%83%83%E3%82%B0%E3%83%AD%E3%82%B0%E5%87%BA%E5%8A%9B">挙動確認のためのデバッグログ出力</a></h1>
<p>デバッグ用の表示を同じターミナルに出力すると混ざって分かりにくいので、ファイルに出力して別ターミナルで <code>tail -F</code> することに。</p>
<pre><code class="ruby">$log = File.open("debug.log", "a")
def debug(*args)
$log.puts *args
$log.flush
end
</code></pre>
<h1 id="履歴の保存・復元"><a href="#%E5%B1%A5%E6%AD%B4%E3%81%AE%E4%BF%9D%E5%AD%98%E3%83%BB%E5%BE%A9%E5%85%83">履歴の保存・復元</a></h1>
<p>同じ入力を繰り返すのは面倒なので履歴まわりを用意しておきます。</p>
<p><code>Reline.readmultiline</code> の第2引数 <code>add_hist</code> を true にすると Reline が履歴を覚えてくれて、カーソルキー上下や <code>ctrl-n</code> <code>ctrl-p</code> で履歴を辿れるようになります。</p>
<pre><code class="ruby"> text = Reline.readmultiline(PROMPT, true) do |input| ...
</code></pre>
<p>一度終了して次回実行したときに前回までの履歴が復元されてほしいので、シリアライズしてファイルに保存します。とりあえず JSON で適当に。</p>
<pre><code class="ruby">require "json"
HISTORY_FILE = "history"
def add_history(text)
File.open(HISTORY_FILE, "a") { |f| f.puts JSON.generate(text) }
end
def load_history
return unless File.exist?(HISTORY_FILE)
File.read(HISTORY_FILE).each_line do |json|
Reline::HISTORY << JSON.parse(json)
end
end
</code></pre>
<h1 id="Reline.readmultiline のブロック"><a href="#Reline.readmultiline+%E3%81%AE%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF">Reline.readmultiline のブロック</a></h1>
<pre><code class="ruby"> text =
Reline.readmultiline(PROMPT, true) do |input|
debug ""
debug "-->> readmultiline block"
debug input.inspect
true
end
</code></pre>
<p>こんな感じでデバッグ出力して動作を見てみると、<code>Enter</code> キーが押されたタイミングでこのブロックが呼び出されているようだぞ、ということが確認できます。</p>
<p><code>11</code> Enter <code>22</code> Enter と入力したときのデバッグ出力:</p>
<pre><code>-->> readmultiline block
"11\n"
-->> readmultiline block
"22\n"
</code></pre>
<hr />
<p>このブロックは <code>LineEditor#confirm_multiline_termination_proc</code> にセットされ、</p>
<pre><code>ed_newline
=> confirm_multiline_termination
=> @confirm_multiline_termination_proc
</code></pre>
<p>という流れで呼び出されるようです。 <code>ed_newline</code> という名前のメソッドから呼ばれているので、改行の入力がトリガーになっていると考えて良さそうな雰囲気です。</p>
<p>ブロックの呼び出しはこのようになっていて、</p>
<pre><code class="ruby">@confirm_multiline_termination_proc.(temp_buffer.join("\n") + "\n")
</code></pre>
<p>各行の末尾に LF の付いた文字列がブロックの引数に渡ってくることが分かります。</p>
<hr />
<p>ブロックの評価値の扱いも見てみます。</p>
<pre><code class="ruby">class Reline::LineEditor
# ...
private def ed_newline(key)
# ...
if confirm_multiline_termination
finish
else
key_newline(key)
end
</code></pre>
<p>真だったら編集完了、偽だったら編集継続となるようです(見た感じでは)。</p>
<hr />
<p>というわけで、</p>
<ol>
<li>編集中の複数行文字列が引数としてブロックに渡ってくる</li>
<li>編集が完了しているかを判断し、完了している場合はブロックの評価値を真にする。途中だったら偽にする。</li>
</ol>
<p>というあたりを踏まえて、ブロックの中身を修正します。例として、末尾が <code>;</code> になっていたら編集完了というルールにします。</p>
<pre><code class="ruby"> text =
Reline.readmultiline(PROMPT, true) do |input|
debug ""
debug "--> readmultiline block"
debug input.inspect
finished = input.strip.end_with?(";")
debug "finished (#{finished})"
finished
end
</code></pre>
<p><code>11</code> Enter <code>;22</code> Enter <code>;</code> Enter と入力したときのデバッグ出力:</p>
<pre><code>-->> readmultiline block
"11\n"
finished (false) ... まだ編集の途中
-->> readmultiline block
"11\n;22\n"
finished (false) ... まだ編集の途中
-->> readmultiline block
"11\n;22\n;\n"
finished (true) ... 末尾が ; なので編集が完了したと判断
</code></pre>
<p>なるほど。基本的なことがやりたいだけであればこのくらい分かっていれば良さそうですね。</p>
<h1 id="プロンプトをカスタマイズする"><a href="#%E3%83%97%E3%83%AD%E3%83%B3%E3%83%97%E3%83%88%E3%82%92%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA%E3%81%99%E3%82%8B">プロンプトをカスタマイズする</a></h1>
<p>たとえば、最初の行とそれ以外で異なるプロンプトを表示したいといった場合、 <code>Reline.prompt_proc</code> に Proc オブジェクトをセットすることでカスタマイズできるようです。</p>
<pre><code class="ruby">PROMPT = "> "
Reline.prompt_proc =
Proc.new do |lines|
lines.each_with_index.map do |line, i|
i == 0 ? PROMPT : "| "
end
end
</code></pre>
<pre><code>$ ruby sample.rb
> 11
| 22
| ;
text (11
22
;)
>
</code></pre>
<p>この場合、<code>Reline.prompt_proc</code> で生成したプロンプト文字列が優先して使われ、<code>Reline.readmultiline</code> の第一引数で渡したプロンプト文字列は使われなくなるようです(表面的な挙動を見た感じでは)。</p>
<p>ただし、<code>Reline.readmultiline</code> の第一引数で渡したプロンプト文字列と長さが異なっていると履歴を移動した際にカーソル位置がずれるので、とりあえず同じ長さにしておくと良いようです。</p>
<h1 id="まとめたもの"><a href="#%E3%81%BE%E3%81%A8%E3%82%81%E3%81%9F%E3%82%82%E3%81%AE">まとめたもの</a></h1>
<pre><code class="ruby">require "reline"
require "json"
HISTORY_FILE = "history"
PROMPT = "> "
$log = File.open("debug.log", "a")
def debug(*args)
$log.puts *args
$log.flush
end
def add_history(text)
File.open(HISTORY_FILE, "a") { |f| f.puts JSON.generate(text) }
end
def load_history
return unless File.exist?(HISTORY_FILE)
File.read(HISTORY_FILE).each_line do |json|
Reline::HISTORY << JSON.parse(json)
end
end
def finished?(input)
stripped = input.strip
return true if stripped == "exit"
return true if stripped.end_with?(";")
false
end
Reline.prompt_proc =
Proc.new do |lines|
lines.each_with_index.map do |line, i|
i == 0 ? PROMPT : "| "
end
end
load_history
loop do
text =
Reline.readmultiline(PROMPT, true) do |input|
debug ""
debug "-->> readmultiline block"
debug input.inspect
finished = finished?(input)
debug "finished (#{finished})"
finished
end
add_history text
# 編集が完了した複数行文字列を使った処理
puts "text (#{text})"
break if text == "exit"
end
</code></pre>
<h1 id="メモ"><a href="#%E3%83%A1%E3%83%A2">メモ</a></h1>
<ul>
<li>使用例については irb のソースも見てみると良さそう
<ul>
<li>たとえば v3.0.1 だとここで使われています<br />
<a target="_blank" rel="nofollow noopener" href="https://github.com/ruby/ruby/blob/v3_0_1/lib/irb/input-method.rb#L319">https://github.com/ruby/ruby/blob/v3_0_1/lib/irb/input-method.rb#L319</a></li>
</ul></li>
</ul>
<h1 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h1>
<ul>
<li>2019-12-26 <a target="_blank" rel="nofollow noopener" href="https://eh-career.com/engineerhub/entry/2019/12/26/103000">Ruby 2.7のここがすごい! パターンマッチ、コンパクションGCなどをリリースマネージャーに聞いた - エンジニアHub|Webエンジニアのキャリアを考える!</a></li>
</ul>
<h1 id="関連"><a href="#%E9%96%A2%E9%80%A3">関連</a></h1>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/20210418_reline_readmultiline_mysql_wrapper">Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/archive/category/Ruby">他に Ruby 関連で書いたもの</a></li>
</ul>
sonota486