100行ちょっとで CSS3Selectors の tokenizer を書いてみた
CSSセレクタの話題って1年半ぶりぐらい。
知らない間に CSS3 Selectors Level 3が出てたので、tokenizer を書いてみました。
(function() { window.tokenizer = tokenizer; var _A_TAG = 1, // E _A_COMBINATOR = 2, // E > F _A_ID = 3, // #ID _A_CLASS = 4, // .CLASS _A_ATTR = 5, // [ATTR] _A_ATTR_VALUE = 6, // [ATTR="VALUE"] _A_PSEUDO = 7, // :target _A_PSEUDO_FUNC = 8, // :lang(...) :nth-child(...) _A_PSEUDO_NOT = 9, // :not(...) _A_COMMA = 10, // E,F _COMB = /^\s*(?:([>+~])\s*)?(\*|\w*)/, // "E > F" "E + F" "E ~ F" "E" "E F" "*" _ATTR = /^\[\s*(?:([^~\^$*|=\s]+)\s*([~\^$*|]?\=)\s*((["'])?.*?\4)|([^\]\s]+))\s*\]/, _MARK = { "#": 1, ".": 2, "[": 3, ":": 4 }, // ] _COMMA = /^\s*,\s*/, _IDENT = /^[#\.]([a-z_\u00C0-\uFFEE\-][\w\u00C0-\uFFEE\-]*)/i, // #ID or .CLASS _PSEUDO = { E: /^(\w+|\*)\s*\)/, END: /^\s*\)/, FUNC: /^\s*([\+\-\w]+)\s*\)/, FIND: /^:([\w\-]+\(?)/ }, _PSEUDOS = { // pseudo root: 1, "first-child": 2, "last-child": 3, "first-of-type": 4, "last-of-type": 5, "only-child": 6, "only-of-type": 7, empty: 8, link: 9, visited: 10, active: 11, hover: 12, focus: 13, target: 14, enabled: 15, disabled: 16, checked: 17, // pseudo functions "not(": 18, "lang(": 19, "nth-child(": 20, "nth-of-type(": 21, "nth-last-child(": 22, "nth-last-of-type(": 23 // )))))) }; function tokenizer(expr) { var rv = { token: [], group: 1, err: false, msg: "" }, m, outer, inner; expr = expr.trim(); while (!rv.err && expr && outer !== expr) { // outer loop m = _COMB.exec(outer = expr); if (m) { m[1] && rv.token.push(_A_COMBINATOR, m[1]); // >+~ rv.token.push(_A_TAG, m[2] || "*"); // tag expr = expr.slice(m[0].length); } while (!rv.err && expr && inner !== expr) { // inner loop expr = innerLoop(inner = expr, rv); } m = _COMMA.exec(expr); if (m) { ++rv.group; rv.token.push(_A_COMMA); expr = expr.slice(m[0].length); } } return rv; } function innerLoop(expr, rv, not) { var m, num; switch (_MARK[expr.charAt(0)] || 0) { case 1: (m = _IDENT.exec(expr)) && rv.token.push(_A_ID, m[1]); break; case 2: (m = _IDENT.exec(expr)) && rv.token.push(_A_CLASS, m[1]); break; case 3: m = _ATTR.exec(expr); // [1]ATTR, [2]OPERATOR, [3]"VALUE" [5]ATTR if (m) { m[5] ? rv.token.push(_A_ATTR, m[5]) : rv.token.push(_A_ATTR_VALUE, m[1], m[2], m[3]); } break; case 4: m = _PSEUDO.FIND.exec(expr); if (m) { num = _PSEUDOS[m[1]] || 0; if (!num) { rv.err || (rv.err = !!(rv.msg = m[0])); } else if (num <= 17) { // 17: checked rv.token.push(_A_PSEUDO, m[1]); } else if (num === 18) { // 18: not if (not) { rv.err = !!(rv.msg = ":not(:not(...))"); break; } rv.token.push(_A_PSEUDO_FUNC, "not"); expr = expr.slice(m[0].length); m = _PSEUDO.E.exec(expr); if (m) { rv.token.push(_A_TAG, m[1]); } else { expr = innerLoop(expr, rv, 1); // :not(simple selector) m = _PSEUDO.END.exec(expr); m || (rv.err ? 0 : (rv.err = !!(rv.msg = ":not()"))); } } else { // :lang(fr) rv.token.push(_A_PSEUDO_FUNC, m[1].slice(0, -1)); expr = expr.slice(m[0].length); m = _PSEUDO.FUNC.exec(expr); m ? rv.token.push(m[1]) : rv.err ? 0 : (rv.err = !!(rv.msg = m[0])); } } } m && (expr = expr.slice(m[0].length)); return expr; } })();
:contains() や E[ATTR!=VALUE] などの非標準な機能は未実装。
実行結果
tokenizer( 'div > p[a^="b"] + div:nth-child(even), E[ATTR=", F"], F' ) を実行するとこういったHashを返します。パースエラーで err が true になり msg にそれっぽい文言が入ります。
{ token: [ 1, "div", 2, ">", 1, "p", 6, "a", "^=", '"b"', 2, "+", 1, "div", 8, "nth-child", "even", 10, 1, "E", 6, "ATTR", "=", '", F"', 10, 1, "F" ], group: 3, err: false, msg: "" }
前書いた奴(uuQuery.js)は、トークンの切り出しと実際に要素を選択する処理が一体化してたから、よく使われるCSSセレクタのパース結果をキャッシュして再利用することもできなかったし、#ID を見つけたら最適化してマッチングするとかできなかったんだけど、分離しとけばそういうのもなんとかなるかな〜と。
スタイルシートのユニットテストツールのようなことをしたり、CSSの枠を超えて楽しいことをやるには、こういう基本的な部品からコツコツ準備しないとだめなんですよね。