Notepad++のXMLファイルをソートして整形して出力する:ワシのコードは4発目まであるぞ

id:eel3:20110306:1299399719 のリベンジ。で、これで打ち止め。

XSLTを使ってノードをソートした所、xsl:sortのソート順がNotepad++の要求する順序と異なっていて問題となった。あれから少し調べたものの、このソート順の問題をXSLTにて解決する方法は見つかっていない。

そこでXSLTから離れてみることにしたのだが……XSLTの次はXML DOMかなあと思いついた。Windows XPで作業しているので、MSXMLを使ってみることにした。

そうなると手っ取り早いのはWSHからMSXMLを使用する方法になると思うが、XMLを操作するならJavaScriptだろうということで、VBScriptではなくJScriptで実装してみた。

<?xml version="1.0" standalone="yes" encoding="Shift_JIS" ?>

<!--
  @brief   Notepad++の関数補完用の設定XMLファイルをキーワード順にソートするツール
  @author  eel3 @ TRASH BOX
  @date    2011/03/13
  @note    JScript on WSH 5.7 @ Windows XP Professional SP3
-->

<package>

<job id="transxml">
<runtime>
  <description>Sorting tool for Notepad++ auto-complete XML file, using MSXML.</description>
  <named name="in" type="string" required="false"
         helpstring="(Optional) Source XML document file." />
  <named name="out" type="string" required="false"
         helpstring="(Optional, but RECOMMEND) Output file.." />
  <example><![CDATA[
EXAMPLE : 
    cscript transxml.wsf //Nologo /in:foo.xml /out:bar.xml
    cscript transxml.wsf //Nologo /in:foo.xml > bar.xml
    cscript transxml.wsf //Nologo /out:bar.xml < foo.xml
    cscript transxml.wsf //Nologo < foo.xml > bar.xml
]]></example>
</runtime>

<script language="JScript"><![CDATA[

(function() {
    /* ---------------------------------------------------------------------- */
    // 汎用関数
    /* ---------------------------------------------------------------------- */

    /// このスクリプトを実行しているエンジンの名前を取得する。
    /// cscript, wscript, WScript の何れかが返ってくるはず。
    var engineName = (function () {
        var fsys = WScript.CreateObject("Scripting.FileSystemObject");
        var name = fsys.GetBaseName(WScript.FullName);
        return function () { return name; };
    }());

    /// コンソールで実行中ならtrueを返す
    var isConsole = function () {
        return (engineName() === "cscript") ? true : false;
    };

    // wscript で実行する場合、使用可能なメッセージ表示関数は Echo のみ。
    // cscript なら標準出力と標準エラー出力を分けて使用できる。

    /// メッセージを表示する
    var print = function (s) { WScript.Echo(s); };

    /// エラーメッセージを表示する
    var eprint = (function () {
        if (isConsole()) {
            return function (s) { WScript.StdErr.WriteLine(s); };
        } else {
            return function (s) { WScript.Echo(s); };
        }
    }());

    /// path を絶対パスに変換して返す
    var absolutePath = function (path) {
        return new ActiveXObject("Scripting.FileSystemObject").GetAbsolutePathName(path);
    };


    /* ---------------------------------------------------------------------- */
    // XML操作用関数
    /* ---------------------------------------------------------------------- */

    /// url からXMLファイルを読み込み、XMLドキュメントオブジェクトを返す
    var loadXMLFile = function (url) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.async = false;
        doc.load(url);
        return doc;
    };

    /// XMLドキュメントの文字列 str を解析し、XMLドキュメントオブジェクトを返す
    var parseXML = function (str) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.loadXML(str);
        return doc;
    };

    /// ドキュメント xml にスタイルシート xslt を適用し、
    /// その結果をXMLドキュメントオブジェクトとして返す
    var applyXSLT = function (xml, xslt) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.async = false;
        xml.transformNodeToObject(xslt, doc);
        return doc;
    };


    /* ---------------------------------------------------------------------- */
    // 
    /* ---------------------------------------------------------------------- */

    /// ソートする前に不要な要素等を取り除くためのXSLT
    // XXX encoding指定は不要ないし間違っている可能性がある(XML宣言、xsl:output双方とも)
    // XXX xsl:outputの属性indentは不要
    var FILTER_ELEMS = [
        '<?xml version="1.0" encoding="Windows-1252" ?>',
        '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
          '<xsl:output method="xml" encoding="Windows-1252" indent="yes" />',

          '<xsl:template match="/">',
            '<xsl:apply-templates />',
          '</xsl:template>',

          '<xsl:template match="NotepadPlus">',
            '<xsl:copy>',
              '<xsl:apply-templates />',
            '</xsl:copy>',
          '</xsl:template>',

          '<xsl:template match="AutoComplete">',
            '<xsl:copy>',
              '<xsl:for-each select="@*">',
                '<xsl:copy />',
              '</xsl:for-each>',
              '<xsl:copy-of select="Environment" />',
              '<xsl:for-each select="KeyWord">',
                '<xsl:copy-of select="." />',
              '</xsl:for-each>',
            '</xsl:copy>',
          '</xsl:template>',

        '</xsl:stylesheet>'
    ].join("");

    /// XMLを整形しながら全ノードをコピーするXSLT。
    /// 子要素のノード数で処理を分けているのは、空要素タグを空要素タグで出力する為
    /// (xsl:otherwise側の処理では<tag/>が<tag></tag>と出力されてしまう)。
    // XXX XML宣言部のencoding指定は不要か、間違っている可能性がある
    var COPY_WITH_INDENT = [
        '<?xml version="1.0" encoding="Windows-1252" ?>',
        '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
          '<xsl:output method="xml" encoding="Windows-1252" indent="yes" />',

          '<xsl:template match="*">',
            '<xsl:text>\n</xsl:text>',
            '<xsl:choose>',
              '<xsl:when test="count(./*) = 0">',
                '<xsl:copy-of select="." />',
              '</xsl:when>',
              '<xsl:otherwise>',
                '<xsl:copy>',
                  '<xsl:for-each select="@*">',
                    '<xsl:copy />',
                  '</xsl:for-each>',
                  '<xsl:apply-templates />',
                '</xsl:copy>',
              '</xsl:otherwise>',
            '</xsl:choose>',
            '<xsl:text>\n</xsl:text>',
          '</xsl:template>',

        '</xsl:stylesheet>'
    ].join("");

    /// XMLドキュメント xml をKeyWord要素の属性name順にソートしたものに変換し、
    /// その結果をXMLドキュメントオブジェクトとして返す
    var transform = function (xml) {
        // node の子要素にKeyWord要素があったらソートする
        var sort_keyword_elem = function (node) {
            var keyword_elem, len, copies, i;

            keyword_elem = node.selectNodes("KeyWord");
            len = keyword_elem.length;
            if (len === 0) {
                for (i = 0, len = node.childNodes.length; i < len; ++i) {
                    sort_keyword_elem(node.childNodes[i]);
                }
            } else {
                copies = [];
                for (i = 0; i < len; ++i) {
                    copies.push(keyword_elem[i].cloneNode(true));
                }
                keyword_elem.removeAll();

                copies.sort(function (a, b) {
                    // String.localeCompareでは思った通りにソートされなかった
                    if (a.getAttribute("name") < b.getAttribute("name")) {
                        return -1;
                    } else if (a.getAttribute("name") > b.getAttribute("name")) {
                        return 1;
                    } else {
                        return 0;
                    }
                });
                for (i = 0, len = copies.length; i < len; ++i) {
                    node.appendChild(copies[i]);
                }
            }
        };

        var doc = applyXSLT(xml, parseXML(FILTER_ELEMS));
        sort_keyword_elem(doc.documentElement);

        return applyXSLT(doc, parseXML(COPY_WITH_INDENT));
    };


    /* ---------------------------------------------------------------------- */
    // 
    /* ---------------------------------------------------------------------- */

    /// メインルーチン
    WScript.Quit((function (progname, args) {
        var EXIT_SUCCESS = 0;
        var EXIT_FAILURE = 1;
        var opts = args.Named;
        var xmldoc, newdoc;

        if (isConsole() === false) {
            eprint("Please use cscript.exe");
            return EXIT_FAILURE;
        }

        if (opts.length > 2) {
            eprint("invalid arguments");
            return EXIT_FAILURE;
        }
        if (opts.Exists("in")) {
            xmldoc = loadXMLFile(absolutePath(opts("in")));
        } else {
            xmldoc = parseXML(WScript.StdIn.ReadAll());
        }

        newdoc = transform(xmldoc);
        if (opts.Exists("out")) {
            newdoc.save(absolutePath(opts("out")));
        } else {
            print(newdoc.xml);
        }

        return EXIT_SUCCESS;

    }(WScript.ScriptName, WScript.Arguments)));
}());

]]></script>
</job>

</package>

ヘルプ機能を入れたい場合、WSFは便利だと思う。

ソースコード中にXSLTらしき文字列が入っているが、これは入力されたXMLのフィルタリングと出力するXMLの整形を行っている。効率面で懸念はあるが、全てXML DOMで操作するよりもXSLTを併用した方が楽だと感じる。

整形出力用のXSLTにて下位ノード数に応じて処理を変えているが、これは元々空要素タグで記述された要素を空要素タグのスタイルで出力する為の小細工だ。使用するXSLTプロセッサやDTDの有無によって異なると思うが、少なくともMSXMLXSLTプロセッサを使用してDTD無しの場合、xsl:otherwise側に記述した処理だと空要素タグではないスタイルになってしまう。そこでxsl:copy-ofを使用して空要素タグとして記述された要素をそっくりそのままコピーしている。

出力先がファイルの場合と標準出力の場合とで出力処理が異なるが、これはXMLからXMLに変換する場合に出力されるXSL宣言のencoding指定が出力されない問題を回避する為だ。どうもMSXML2.DOMDocument::saveでファイルに出力する場合は正しいencodingになるのだが、それ以外だとencodingがUTF-16になってしまったり*1そもそもencodingが出力されなかったりするようだ。

ところでこのスクリプトはxsl:copy-ofを使っている為か、下位ノードが存在せず且つ空要素タグではないスタイル*2で記述した要素もそのままのスタイルで出力される。これを空要素タグのスタイルに統一したいので、コードを手直ししてみた。

<?xml version="1.0" standalone="yes" encoding="Shift_JIS" ?>

<!--
  @brief   Notepad++の関数補完用の設定XMLファイルをキーワード順にソートするツール
  @author  eel3 @ TRASH BOX
  @date    2011/03/13
  @note    JScript on WSH 5.7 @ Windows XP Professional SP3
-->

<package>

<job id="transxml">
<runtime>
  <description>Sorting tool for Notepad++ auto-complete XML file, using MSXML.</description>
  <named name="in" type="string" required="false"
         helpstring="(Optional) Source XML document file." />
  <named name="out" type="string" required="false"
         helpstring="(Optional, but RECOMMEND) Output file.." />
  <example><![CDATA[
EXAMPLE : 
    cscript transxml.wsf //Nologo /in:foo.xml /out:bar.xml
    cscript transxml.wsf //Nologo /in:foo.xml > bar.xml
    cscript transxml.wsf //Nologo /out:bar.xml < foo.xml
    cscript transxml.wsf //Nologo < foo.xml > bar.xml
]]></example>
</runtime>

<script language="JScript"><![CDATA[

(function() {
    /* ---------------------------------------------------------------------- */
    // 汎用関数
    /* ---------------------------------------------------------------------- */

    /// このスクリプトを実行しているエンジンの名前を取得する。
    /// cscript, wscript, WScript の何れかが返ってくるはず。
    var engineName = (function () {
        var fsys = WScript.CreateObject("Scripting.FileSystemObject");
        var name = fsys.GetBaseName(WScript.FullName);
        return function () { return name; };
    }());

    /// コンソールで実行中ならtrueを返す
    var isConsole = function () {
        return (engineName() === "cscript") ? true : false;
    };

    // wscript で実行する場合、使用可能なメッセージ表示関数は Echo のみ。
    // cscript なら標準出力と標準エラー出力を分けて使用できる。

    /// メッセージを表示する
    var print = function (s) { WScript.Echo(s); };

    /// エラーメッセージを表示する
    var eprint = (function () {
        if (isConsole()) {
            return function (s) { WScript.StdErr.WriteLine(s); };
        } else {
            return function (s) { WScript.Echo(s); };
        }
    }());

    /// path を絶対パスに変換して返す
    var absolutePath = function (path) {
        return new ActiveXObject("Scripting.FileSystemObject").GetAbsolutePathName(path);
    };


    /* ---------------------------------------------------------------------- */
    // XML操作用関数
    /* ---------------------------------------------------------------------- */

    /// url からXMLファイルを読み込み、XMLドキュメントオブジェクトを返す
    var loadXMLFile = function (url) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.async = false;
        doc.load(url);
        return doc;
    };

    /// XMLドキュメントの文字列 str を解析し、XMLドキュメントオブジェクトを返す
    var parseXML = function (str) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.loadXML(str);
        return doc;
    };

    /// ドキュメント xml にスタイルシート xslt を適用し、
    /// その結果をXMLドキュメントオブジェクトとして返す
    var applyXSLT = function (xml, xslt) {
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.async = false;
        xml.transformNodeToObject(xslt, doc);
        return doc;
    };


    /* ---------------------------------------------------------------------- */
    // 
    /* ---------------------------------------------------------------------- */

    /// ソートする前に不要な要素等を取り除くためのXSLT
    // XXX encoding指定は不要ないし間違っている可能性がある(XML宣言、xsl:output双方とも)
    // XXX xsl:outputの属性indentは不要
    var FILTER_ELEMS = [
        '<?xml version="1.0" encoding="Windows-1252" ?>',
        '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
          '<xsl:output method="xml" encoding="Windows-1252" indent="yes" />',

          '<xsl:template match="/">',
            '<xsl:apply-templates />',
          '</xsl:template>',

          '<xsl:template match="NotepadPlus">',
            '<xsl:copy>',
              '<xsl:apply-templates />',
            '</xsl:copy>',
          '</xsl:template>',

          '<xsl:template match="AutoComplete">',
            '<xsl:copy>',
              '<xsl:for-each select="@*">',
                '<xsl:copy />',
              '</xsl:for-each>',
              '<xsl:copy-of select="Environment" />',
              '<xsl:for-each select="KeyWord">',
                '<xsl:copy-of select="." />',
              '</xsl:for-each>',
            '</xsl:copy>',
          '</xsl:template>',

        '</xsl:stylesheet>'
    ].join("");

    /// XMLを整形しながら全ノードをコピーするXSLT。
    /// 子要素のノード数で処理を分けているのは、空要素タグを空要素タグで出力する為
    /// (xsl:otherwise側の処理では<tag/>が<tag></tag>と出力されてしまう)。
    // XXX XML宣言部のencoding指定は不要か、間違っている可能性がある
    var COPY_WITH_INDENT = [
        '<?xml version="1.0" encoding="Windows-1252" ?>',
        '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
          '<xsl:output method="xml" encoding="Windows-1252" indent="yes" />',

          '<xsl:template match="*">',
            '<xsl:text>\n</xsl:text>',
            '<xsl:choose>',
              '<xsl:when test="count(./*) = 0">',
                '<xsl:copy-of select="." />',
              '</xsl:when>',
              '<xsl:otherwise>',
                '<xsl:copy>',
                  '<xsl:for-each select="@*">',
                    '<xsl:copy />',
                  '</xsl:for-each>',
                  '<xsl:apply-templates />',
                '</xsl:copy>',
              '</xsl:otherwise>',
            '</xsl:choose>',
            '<xsl:text>\n</xsl:text>',
          '</xsl:template>',

        '</xsl:stylesheet>'
    ].join("");

    /// XMLドキュメント xml をKeyWord要素の属性name順にソートしたものに変換し、
    /// その結果をXMLドキュメントオブジェクトとして返す
    var transform = function (xml) {
        // KeyWord要素をソートしつつ、ノード node を to にコピーしていく
        var clone = function (node, to) {
            var elem = doc.createElement(node.nodeName);
            var i, len, keyword_elem;

            for (i = 0, len = node.attributes.length; i < len; ++i) {
                elem.setAttribute(node.attributes[i].name, node.attributes[i].value);
            }

            keyword_elem = [];
            for (i = 0, len = node.childNodes.length; i < len; ++i) {
                if (node.childNodes[i].nodeName === "KeyWord") {
                    keyword_elem.push(node.childNodes[i]);
                } else {
                    clone(node.childNodes[i], elem);
                }
            }

            keyword_elem.sort(function(a, b) {
                // String.localeCompareでは思った通りにソートされなかった
                if (a.getAttribute("name") < b.getAttribute("name")) {
                    return -1;
                } else if (a.getAttribute("name") > b.getAttribute("name")) {
                    return 1;
                } else {
                    return 0;
                }
            });
            for (i = 0, len = keyword_elem.length; i < len; ++i) {
                clone(keyword_elem[i], elem);
            }

            to.appendChild(elem);
        };

        var filtered = applyXSLT(xml, parseXML(FILTER_ELEMS));
        var doc = new ActiveXObject("MSXML2.DOMDocument");
        doc.async = false;
        clone(filtered.documentElement, doc);

        return applyXSLT(doc, parseXML(COPY_WITH_INDENT));
    };


    /* ---------------------------------------------------------------------- */
    // 
    /* ---------------------------------------------------------------------- */

    /// メインルーチン
    WScript.Quit((function (progname, args) {
        var EXIT_SUCCESS = 0;
        var EXIT_FAILURE = 1;
        var opts = args.Named;
        var xmldoc, newdoc;

        if (isConsole() === false) {
            eprint("Please use cscript.exe");
            return EXIT_FAILURE;
        }

        if (opts.length > 2) {
            eprint("invalid arguments");
            return EXIT_FAILURE;
        }
        if (opts.Exists("in")) {
            xmldoc = loadXMLFile(absolutePath(opts("in")));
        } else {
            xmldoc = parseXML(WScript.StdIn.ReadAll());
        }

        newdoc = transform(xmldoc);
        if (opts.Exists("out")) {
            newdoc.save(absolutePath(opts("out")));
        } else {
            print(newdoc.xml);
        }

        return EXIT_SUCCESS;

    }(WScript.ScriptName, WScript.Arguments)));
}());

]]></script>
</job>

</package>

変更したのは関数transformの中身だ。最初のバージョンではXMLドキュメントオブジェクトを直接変更してKeyWord要素をソートしていたのだが、このコードでは新たにオブジェクトを生成している。恐らく手元のMSXMLに依存したコードだと思うが、新たにノードを生成した際に子要素を持たないなら空要素タグのスタイルで出力されるようだ*3

Ruby + REXMLによる実装に始まり、XSLTWSH + JScript + MSXMLXML DOM + XSLT)と手を替え品を替え同一目的のツールを作ってきたが、ようやく挙動面で許容できる水準になった。

*1:しかし実際の文字コードUTF-16ではない模様。

*2:例えば

*3:DTDが存在する場合はまた異なってくるかもしれない。