お題
複数行の文章を任意の箇所に挿入するワンライナーを書く
ファイル中の任意の箇所、例えば複数行にわたる正規表現にマッチした箇所の下に、複数行の文章を挿入するとき、Rubyのワンライナーでどのように書くかを考えてみる。またsedでの対処法も考えてみる。
今回編集するファイル名はtest.shとする。test.shにはあるif文が書かれた箇所があり、そのif文の下にもう一つif文を入れたいとする。
test.sh
#!/bin/bash
(略)
if [ $cond = http ]; then
port=80
fi
(略)
挿入したいコード
if [ $cond = https ]; then
port=443
fi
期待する編集後のファイル
#!/bin/bash
(略)
if [ $cond = http ]; then
port=80
fi
if [ $cond = https ]; then
port=443
fi
(略)
Rubyによる解法
1行を挿入するだけであれば簡単だが、複数行となると少し難しくなる。ワンライナーで実現するには、catを使ってヒアドキュメントを標準出力に出して、それをワンライナーに入れ込む。
Rubyの正規表現とFile.read, File.writeを使う方法
File.readを使って全文読み込み、複数行にわたる正規表現で挿入したい箇所の行を検索する。マッチした箇所を後方参照で再度使い、その後ろに標準出力の内容も入れる。標準出力の内容は、RubyがSTDIN.readで全文読み込む。置換後の新しい文字列をFile.writeでファイルに上書きして、編集完了とする。
cat << 'EOS' | ruby -e 'f="test.sh"; lines = File.read(f).gsub(/(.*cond = http(.*\n)+?[ ]*fi\n)/, "\\1#{STDIN.read}"); File.write(f, lines)'
if [ $cond = https ];then
port=443
fi
EOS
置換後の全文を変数linesに入れているが、変数に格納せず、直接File.writeの第二引数に入れ込むと少し短くなる。
cat << 'EOS' | ruby -e 'f="test.sh"; File.write(f, File.read(f).gsub(/(.*cond = http(.*\n)+?[ ]*fi\n)/, "\\1#{STDIN.read}"))'
if [ $cond = https ];then
port=443
fi
EOS
しかしまだファイル名を変数に格納している箇所があり、厳密にはワンライナーとは言えない。Rubyワンライナーの引数にファイル名を渡し、ARGV[0]
でファイル名を取得するようにする。
cat << 'EOS' | ruby -e 'File.write(ARGV[0], File.read(ARGV[0]).gsub(/(.*cond = http(.*\n)+?[ ]*fi\n)/, "\\1#{STDIN.read}"))' test.sh
if [ $cond = https ];then
port=443
fi
EOS
Rubyの正規表現とin-placeを使う方法
先ほどの方法だと、ファイルのread/writeメソッドを呼び出す箇所が野暮ったい。
せっかくのワンライナーなので、Rubyのiオプションを使って、sed -iのように引数に渡したファイルをin-placeでそのまま編集することで、ファイルのwrite部分に相当する箇所が不要になり、記述が簡潔になる。
cat << 'EOS' | ruby -i -e 'puts ARGF.read.gsub(/(.*cond = http(.*\n)+?[ ]*fi\n)/, "\\1#{STDIN.read}")' test.sh
if [ $cond = https ];then
port=443
fi
EOS
Rubyの引数にファイル名を入れているのでARGFでファイルを参照すると同時に、標準入力を読み込むようにSTDIN.readと書いている。
gsubで置換した後の全文文字列をputsすることで、その内容がそのままファイルに書き込まれる。
sedによる解法
ところで、Rubyではなくsedを使えば文字列挿入部分については遥かに簡潔に書ける。sedのaコマンドとiコマンドでそれぞれ条件に合致する行の下や上に新しい文字列を挿入できるのは有名だ。
a \
text Append text, which has each embedded newline preceded by a backslash.
i \
text Insert text, which has each embedded newline preceded by a backslash.
これに加えてrコマンドというものがあり、ファイルに書かれた内容を挿入できるので、挿入したい文字列が複数行にわたったり複雑な文字列であっても簡単に書ける。
r filename
Append text read from filename.
参考: ファイル・標準出力から読み込んで行を追記する @ sedコマンドで覚えておきたい使い方12個(+3個)
ただしRubyと違って行指向処理のsedだと複数行にマッチする正規表現が書けないので、挿入するポイントが簡単に取得できない。ひとまず目視で挿入すべき行数(ここでは107行目の後ろに挿入する)を確認して処理を進める。
別のファイルの内容をsedで挿入する方法
まずはヒアドキュメントでlines.txtに挿入したい文字を出力する。
cat << 'EOS' > lines.txt
if [ $cond = https ];then
port=443
fi
EOS
rコマンドを使用して指定した行の後ろにファイルの文字列を追加する。
sed -i '107r lines.txt' test.sh
lines.txtという中間ファイルができてしまった。中間ファイルができるのが嫌なので、中間ファイルを使わない方法をとる。
中間ファイルなしでsedで挿入する方法
ファイル名に標準入力/dev/stdinを指定すると標準入力の内容を挿入してくれるので、ヒアドキュメントの標準出力をsedにパイプしてsedは標準入力として処理する。
cat << 'EOS' | sed -i '107r /dev/stdin' test.sh
if [ $cond = https ];then
port=443
fi
EOS
挿入すべき行数が明確にわかっている、あるいは複数行の正規表現にマッチさせて挿入行を算出する必要がなく単一行を検査することで挿入行がわかる、ということであればsedを使った方がRubyよりも簡潔。
Rubyで挿入すべき行数を求めつつ、sedを使う方法
Rubyで行数を求めつつ、in-place編集自体はsedでやるという方法も考えられる。
以下2通りの方法で行数を求めてみる。
一つ目はRubyの複数行への正規表現検索でマッチした箇所のみを出力し、元のファイルとdiff -yしてからgrepすることで行数を求めている。
$ ruby -e 'puts File.read("test.sh").match(/(.*cond = http(.*\n)+?[ ]*fi\n)/)' | diff - test.sh -y | grep -nv '>'
105: if [ $cond = http ];then if [ $cond = http ];then
106: port=80 port=80
107: fi fi
$ ruby -e 'puts File.read("test.sh").match(/(.*cond = http(.*\n)+?[ ]*fi\n)/)' | diff - test.sh -y | grep -nv '>' | cut -d: -f1 | tail -1
107
cat << 'EOS' | sed -i "${lineno}r /dev/stdin" test.sh
if [ $cond = https ];then
port=443
fi
EOS
次の方法はRubyのeach_with_index
でファイルの各行を順番に結合しながら、都度正規表現でマッチするかどうか確認している。マッチしたらindex + 1することで行数を求めている。
lineno=`ruby -e 'str=""; File.readlines("test.sh").each_with_index{|line, i| (str+=line).match(/(.*cond = http(.*\n)+?[ ]*fi\n)/) && (p i+1) && break }'`
行数を求めるのが力技になってしまうので、通常であればRubyとsedの組み合わせではなく、場合に応じてRubyでやるかsedでやるかを決めるのがいい。