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