👿

ElmでForeign Function Interface (強引)

2021/05/08に公開

(前回のお話:Elmのデータの実行時表現を調べてみよう

私の趣味はElmの制約を回避する邪悪なハックを探すことなのですが、他の趣味としてここしばらくはブラウザ上でSVGを編集できるツール (まだまだ開発途中)を作っています。このツールでSVGを読み込むときに、以前は純Elm製のXMLパーサライブラリを使っていたのですが、大きなSVGドキュメントをパースするとどうしても時間がかかってしまうという悲しみを抱えて生きていました。それでやむなくPort越しにDomParserを使うように変更したのですが(これはブラウザでネイティブに実装されているので申し分なく早い)、 XMLのパースは純粋なテキスト処理なのでただの関数一発でできるはずなのに、エンコーダーやデコーダーを書いたりCmdSubを駆使してデータをやり取りしなければならないのはとても面倒 に思っていました。

ちなみにPureScriptだと、純粋な関数であっても Foreign Function Interface (FFI)を使えば中身をJavaScriptで自由に実装できます。でもFFIは下手に使うと、純粋な関数なはずなのに副作用を起こしたり、実行時エラーを起こしたり、不正なデータを紛れ込ませたりといった危険性があります。その点、Elmは安全性やポータビリティを大切にする言語なので、そういう危険なことはユーザーに選択する余地も与えずに完全に禁止されています。

そこで今回は、parseXml : String -> Nodeという純粋な(Portを使わない)Elmの関数を定義し、この関数の中身をJavaScriptのDomParserを使って実装する、という荒業に挑戦したいと思います。ElmにはFFIがなく、Elmの関数の中身をJavaScriptで実装するという方法は通常はありませんが、無理やり実現します。その関数は次のように使うイメージです。

parsed : Node
parsed =
    parseXml """<svg><rect width="10px"></rect><text>Hello</text></svg>"""

formatted : String
formatted =
    nodeToString parsed

それではまず、XMLドキュメントツリーを表すデータ型Nodeと、文字列をパースしてNodeを作成するparseXml、またNodeをインデントをつけて文字列に戻すnodeToStringを定義します。

type Node
    = Text String
    | Element String (List ( String, String )) (List Node)

parseXml : String -> Node
parseXml source =
    Element "p" [] [ Text "REPLACE_ME" ]

nodeToString : Node -> String
nodeToString node =
    case node of
        Text str ->
            str

        Element name attrs children ->
            let
                attrStrs =
                    List.map
                        (\( k, v ) -> " " ++ k ++ "=\"" ++ v ++ "\"")
                        attrs

                indent =
                    String.lines
                        >> List.map (\line -> "    " ++ line)
                        >> String.join "\n"
            in
            [ [ "<" ++ name ++ String.join "" attrStrs ++ ">" ]
            , List.map (indent << nodeToString) children
            , [ "</" ++ name ++ ">" ]
            ]
                |> List.concat
                |> String.join "\n"

このparseXmlの定義には、本当にパースする代わりに"REPLACE_ME"というプレースホルダが埋め込まれています。この時点で なにか嫌な予感 がよぎった人もいるとは思いますが、とにかくこれをコンパイルすると次のようなコードが出力されます。

var $author$project$Main$parseXml = function (source) {
	return A3(
		$author$project$Main$Element,
		'p',
		_List_Nil,
		_List_fromArray(
			[
				$author$project$Main$Text('REPLACE_ME')
			]));
};

このREPLACE_MEのところを置換するスクリプトを適当に書きます。

import fs from "fs";

const source = fs.readFileSync("index.html").toString();

const replaced = source.replace(
  /var \$author\$project\$Main\$parseXml(.|\s)*?};/g,
  `var $author$project$Main$parseXml = function (source) {
    const namedNodeMapToElm = map => {
        let attrs = $elm$core$Maybe$Nothing;
        for(let i = 0; i < map.length; i++){
            attrs = A2(
                $elm$core$List$cons, 
                _Utils_Tuple2(map[i].name, map[i].value), 
                attrs
            );
        }
        return attrs;
    };
    const toElm = node => 
        node.nodeType === 1 ? A3(
                $author$project$Main$Element, 
                node.localName, 
                namedNodeMapToElm(node.attributes), 
                _List_fromArray(Array.prototype.map.call(node.childNodes, toElm)))
            : node.nodeType === 3 ? $author$project$Main$Text(node.textContent)
            : $author$project$Main$Text("");
    const document = new DOMParser().parseFromString(source, "application/xml");
    return toElm(document.documentElement)
};`
);

fs.writeFileSync("index.html", replaced);

コンパイルして、さっきのスクリプトも実行します。

$ npx elm make src/Main.elm
Success!

    Main ---> index.html
   
$ npx ts-node build.ts

それでは実行して表示してみます。

<svg>
    <rect width="10px">
    </rect>
    <text>
        Hello
    </text>
</svg>

やりました。前回の記事は、今回の記事のための下調べでした。でもこれはとても邪悪な方法なので、もちろん良い子は真似してはいけません。

Discussion