[Mac][Zsh] 文字列を置換・整形してきれいなXMLにする

1.承前

前編 のおさらい。

以下のサンプルを My.junit というファイル名で利用しました。

<?xml version='1.0' encoding='UTF-8'?>
<testsuites name='All' tests='5' failures='0' >
  <testsuite name='Tests/AAATests' tests='5' failures='0'>
    <testcase classname='AAATests' name='テストケースA()' time='0.008349895477294922'/>
    <testcase classname='AAATests' name='テストケースB()' time='0.002708911895751953'/>
    <testcase classname='AAATests' name='テストケースC()' time='5.103403783017138107'/>
    <testcase classname='AAATests' name='テストケースD()' time='2.847301841843703481'/>
    <testcase classname='AAATests' name='テストケースE()' time='0.902874301873418053'/>
  </testsuite>
  <testsuite name='Tests/BBBTests' tests='5' failures='0'>
    <testcase classname='BBBTests' name='テストケースA()' time='3.004773974418640137'/>
    <testcase classname='BBBTests' name='テストケースB()' time='0.003311038017272942'/>
    <testcase classname='BBBTests' name='テストケースC()' time='0.003813487107181401'/>
    <testcase classname='BBBTests' name='テストケースD()' time='11.384701784318703814'>
      <failure>Assertion Failure at BBBTests</failure>
    </testcase>
    <testcase classname='BBBTests' name='テストケースE()' time='0.841047132138008907'/>
  </testsuite>
</testsuites>

そして以下のコマンドを実行しました。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5

そうすると、以下のような出力結果を得られました。

11.384701784318703814'> classname='BBBTests' name='テストケースD()'
5.103403783017138107'/> classname='AAATests' name='テストケースC()'
3.004773974418640137'/> classname='BBBTests' name='テストケースA()'
2.847301841843703481'/> classname='AAATests' name='テストケースD()'
0.902874301873418053'/> classname='AAATests' name='テストケースE()'

今回は、その出力結果を更に整形して、人間が見やすいXMLファイルにします!

2.前提条件

  • Mac: macOS Big Sur 11.5.1
  • Zsh: zsh 5.8 (x86_64-apple-darwin20.0)
  • AWK: awk version 20200816
  • Tidy: HTML Tidy for Mac OS X released on 31 October 2006 – Apple Inc. build 1045

3.最終的なコマンド

前編にも載せました が再掲します。

$ echo \<testsuites\> \<testsuite\> `cat YourProject.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's@$@/>@'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8 --wrap 0 --indent true --add-xml-decl true -o output.xml

以降、出力結果を整形していきます!

4. XMLの閉じタグの残骸を除去する

出力結果(再掲) を見ると、>/> など XMLの閉じタグの残骸があります。

11.384701784318703814'> classname='BBBTests' name='テストケースD()'
5.103403783017138107'/> classname='AAATests' name='テストケースC()'
3.004773974418640137'/> classname='BBBTests' name='テストケースA()'
2.847301841843703481'/> classname='AAATests' name='テストケースD()'
0.902874301873418053'/> classname='AAATests' name='テストケースE()'

まずはこれを除去していきます。
閉じタグの残骸を空文字へと置換します。

前編で sed を使って文字列置換をしていきました が、今回も同様にします。

sed による置換は以下のようになります。

sed -e 's/置換前の文字列/置換後の文字列/'

> の置換は以下コマンドになります。

sed -e 's/>//'

となると、/> の置換は以下コマンドになると思いきやそうではありません。

sed -e 's//>//'

上記例だと sed のスクリプトは / で区切りを指定しているため、
置換対象の文字列に / が入っているとスクリプトが正しく解釈されません。

/ をそのままの文字で扱うにはエスケープという手法があります。
そのままの文字として扱いたい(つまりエスケープしたい)文字の前に
バックスラッシュ \ を付けます。

今回の例で言うと以下のようなコマンドになります。

sed -e 's/\/>//'

これでも正しいのですが、個人的には / が多くて読みづらいと感じてしまいます。
(これに関しては慣れの問題なのかも知れません 🤔)

そこで、区切り文字として / 以外の文字を指定します。
そうすることで、/ を区切り文字としてではなく、そのままの文字として扱えます。

sed は区切り文字に \ と改行文字以外は使用できるようです。

Any character other than backslash or newline can be used instead of a slash to delimit the RE and the replacement.

man sed の [2addr]s/regular expression/replacement/flags

/ の次のデファクトは把握していないですが、本ポストでは @ を使うことにします。

というわけで、/> を空白文字にするためには以下のコマンドを使います。

sed -e 's@/>@@'

上記2つの置換を使って、XML の閉じタグの残骸を除去します!

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@'

sed -e ‘s/>//’ と書ける部分の区切り文字に関しても @ で書きました。
また、sed コマンドは繋げて記述できるのでそうしています。

すると以下のような出力になり、XML の閉じタグが除去できました!

11.384701784318703814' classname='BBBTests' name='テストケースD()'
5.103403783017138107' classname='AAATests' name='テストケースC()'
3.004773974418640137' classname='BBBTests' name='テストケースA()'
...略

5.各行をXMLのタグで囲む

最終的にはXMLのフォーマットにしたいので、まずは各行をXMLのタグで囲んでいきます。

ここでも sed を使います。
各行頭と各行末、それぞれに文字を追加していきます。

5-1.各行頭に文字列を追加する

まずは行頭に <testcase time=' という文字列を追加します。
そのためのコマンドは例えば以下になります。

sed -e "s/^/<testcase time='/"

ここでポイントとなるのが、「行頭」の指定方法です。
sed では、と言うよりも正規表現の仕様では、行頭を ^ で表現できます。
参考: The Open Group Library: Regular Expressions

また置換後の文字列にシングルクォートを利用するので、
スクリプトの前後をダブルクォートで囲っています。

ですので行頭に文字列を追加する場合は上記のようになります。

というわけで、全体としては以下のコマンドになります。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/"

すると、以下のように出力されます。

<testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()'
<testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()'
<testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()'
...略

5-2.各行末に文字列を追加する

次に行末に /> という文字列を追加します。
そのためのコマンドは以下になります。

sed -e 's@$@/>@'

上にも書いた通り、区切り文字に / を使ってしまうと
/ そのものの文字列にはエスケープ処理が必要となるので、
簡単のため区切り文字は @ にしてあります。

ここでのポイントは「行末」の指定方法です。

正規表現にて、行頭を表すのに ^ というメタ文字がありました。
それと同様に、行末にも専用のメタ文字があり、それが $ となります。
参考: The Open Group Library: Regular Expressions

そして、全体のコマンドは以下のようになります。

$ cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's@$@/>@'

すると、以下のような出力になります。

<testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()'/>
<testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()'/>
<testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()'/>
...略

だいぶ XML ぽくなりました 🙌

6.全体をXMLのタグで囲む

直前の出力だとルート要素がないので正式なXMLドキュメントではありません。

ですので、テキスト全体の先頭と末尾にタグを追加して、
ルート要素を作っていきたいと思います。

今回は echo を使います。

前回のポストの「最終的なコマンド」でも記載しましたが、
echo を使わずにこの処理も sed でできそうでした。
そちらの方がスマートかもと思いましたが、今回は echo を使います。

例えば以下のコマンドを打つと

echo aaa

以下のように出力されます。

aaa

また、例えば以下のコマンドを打つと

date

以下のように出力されます。

2022年 2月14日 月曜日 00時00分00秒 JST

では、以下のようなコマンドを打つとどうなるかと言うと。

echo date

以下のように出力されます。

date

date がコマンドではなく、文字列として解釈されてしまいます。
echo の際に date をコマンドとして扱うにはどうすればよいか。

バッククォート \で囲います。
以下のようなコマンドにすると。

echo `date`

以下のように出力されます。

2022年 2月14日 月曜日 00時00分00秒 JST

つまり、echo の際 バッククォート で囲むと、
その中身をコマンドとして実行し、その結果を出力する という挙動になります。

これを使うと、以下のようなコマンドを打った場合、

echo AAA `date` BBB

以下のように出力されます。

AAA 2022年 2月14日 月曜日 00時00分00秒 JST BBB

で、今回はJUnit 風の XML にしたいと考えたので、このような文字列を出力したいです。
<testsuites><testsuite><testcaseたち /></testsuite></teststuites>

それをコマンドにすると以下のようになります。

echo \<testsuites\> \<testsuite\> `これまでのコマンド` \<\/testsuite\> \<\/testsuites\>

<> はコマンドのリダイレクトに使用されるため、
バックスラッシュでエスケープしています。

ということで、バッククォートの中身をこれまでのコマンドに置き換えると
以下のようになります。

$ echo \<testsuites\> \<testsuite\> `cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's@$@/>@'` \<\/testsuite\> \<\/testsuites\>

すると以下のように出力されます。

<testsuites> <testsuite> <testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()'/> <testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()'/> <testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()'/> <testcase time='2.847301841843703481' classname='AAATests' name='テストケースD()'/> <testcase time='0.902874301873418053' classname='AAATests' name='テストケースE()'/> </testsuite> </testsuites>

読みづらいですが、XMLのフォーマットになりました!🙌

あとはこれを人間が読みやすいように改行とインデントを加えます。

7.Tidy を使って人間が読みやすい形にXMLを整形する

Mac には xmllint が入っていますが、マルチバイトを認識しない?ようなので、
同じく Mac に入っている Tidy を使います。

Tidy とは HTML ファイルを検証、修正、きれいに出力するコマンドです。
(man tidy より)

Git リポジトリもあります。
また各コマンドのリファレンスもあります。

HTML 用のコマンドですがXMLも扱えます。

さっそく使っていきます。
以下のコマンドでXMLとして扱いつつ、文字コードをUTF-8 に指定します。

$ echo \<testsuites\> \<testsuite\> `cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's/$/\/>/g'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8

すると以下のように出力されます。

<testsuites>
<testsuite>
<testcase time='11.384701784318703814' classname='BBBTests'
name='テストケースD()' />
<testcase time='5.103403783017138107' classname='AAATests'
name='テストケースC()' />
<testcase time='3.004773974418640137' classname='BBBTests'
name='テストケースA()' />
<testcase time='2.847301841843703481' classname='AAATests'
name='テストケースD()' />
<testcase time='0.902874301873418053' classname='AAATests'
name='テストケースE()' />
</testsuite>
</testsuites>

大体良さそうですが、微妙なところに改行があるのが気になりますね。

これは Tidyのワードラッピングの処理が入っているためで、
デフォルトでは68文字が上限となっているようです。

改行は不要にしたいので、--wrap 0 を追加します。

$ echo \<testsuites\> \<testsuite\> `cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's/$/\/>/g'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8 --wrap 0

以下のように表示されます。

<testsuites>
<testsuite>
<testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()' />
<testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()' />
<testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()' />
<testcase time='2.847301841843703481' classname='AAATests' name='テストケースD()' />
<testcase time='0.902874301873418053' classname='AAATests' name='テストケースE()' />
</testsuite>
</testsuites>

いいところまで来ました!
インデントも欲しいので --indent true も追加します。

$ echo \<testsuites\> \<testsuite\> `cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's/$/\/>/g'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8 --wrap 0 --indent true

すると、以下のように表示されます。

<testsuites>
  <testsuite>
    <testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()' />
    <testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()' />
    <testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()' />
    <testcase time='2.847301841843703481' classname='AAATests' name='テストケースD()' />
    <testcase time='0.902874301873418053' classname='AAATests' name='テストケースE()' />
  </testsuite>
</testsuites>

これで完璧ですね!🙌

更に --add-xml-decl true で XML宣言を付与しつつ、
-o output.xml で出力先のファイルを指定します。

$ echo \<testsuites\> \<testsuite\> `cat My.junit | grep 'testcase.*' | awk -F ' ' '{print$4, $2, $3 }' | sed -e "s/time=\'//" | sort -rn | head -5 | sed -e 's@/>@@' -e 's@>@@' -e "s/^/<testcase time='/" -e 's/$/\/>/g'` \<\/testsuite\> \<\/testsuites\> | tidy --input-xml true -utf8 --wrap 0 --indent true --add-xml-decl true -o output.xml

cat output.xml を実行すると以下のように表示されます。

<?xml version="1.0"?>
<testsuites>
  <testsuite>
    <testcase time='11.384701784318703814' classname='BBBTests' name='テストケースD()' />
    <testcase time='5.103403783017138107' classname='AAATests' name='テストケースC()' />
    <testcase time='3.004773974418640137' classname='BBBTests' name='テストケースA()' />
    <testcase time='2.847301841843703481' classname='AAATests' name='テストケースD()' />
    <testcase time='0.902874301873418053' classname='AAATests' name='テストケースE()' />
  </testsuite>
</testsuites>

これで人間にも読みやすい形でファイル出力できました!🙌

これで完成とします!👏


ちなみに Tidy を実行するたびに No warnings or errors were found. などのメッセージが出ます。
表示したくない場合は -q オプションを付けると、表示されなくなります。

8.さいごに

今回は主に sed と Tidy の使い方をまとめました。

前編 も合わせてけっこうな量になりましたが、
慣れると とても簡単なことをやっていると感じられるかと思います。

お疲れ様でした!
よいUNIXライフを!

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。