rshighlite
Rust(wasm)でソースコードのハイライト表示プラグイン作成

投稿日:

以前、このWebサイトのソースコードハイライト表示にWordpressプラグインのCrayon Syntax Highlighterを使っていましたが更新が止まっているようです。代わりのプラグインを探していたのですが、せっかくだから今勉強しているRust(wasm)で作ってみることにしました。現在稼働中です(2019/10/5)。

機能デモ

□rshighlite-Demo

 

プラグイン仕様とか

サーバではなく、Webブラウザ(フロントエンド)の JavaScriptとWebAssembly で動作するプラグイン。 WebAssembly(wasm) はRustをコンパイルして生成する。HTMLのpreタグとcodeタグで囲まれた部分をハイライト処理。名前は、rshighliteです。

<pre><code>
<!--ここから-->
// JavaScript側の関数
extern "C" {
    fn hello_js();
}

//JavaScriptから呼ばれる関数
#[no_mangle]
fn hello_rust() {
    //JavaScriptの関数呼び出しは危険を伴うのでunsafe
    unsafe {    
        hello_js();
    }
}
<!--ここまで-->
</code></pre>

Rust(wasm)

JavaScriptからcodeタグで囲まれた部分をHTML文字列として受け取り、色付けする文字列をspanタグで囲みJavaScriptへ戻す。spanタグのclass名で色を指定する。セキュリティー上の安全を考えて、<br>以外のタグは削除する。
色付けのルールは専用の定義ファイル(構文木)で定義し、構文解析により色付けを実行する。UTF16⇔UTF8の変換はRustが行う。

JavaScript

全体の制御、wasmコード、定義ファイルの読み込み、Rust関数の呼び出しなど。ファイル読み込みなど準備が終了したら、codeタグで囲まれた部分をHTML文字列としてRust関数へ渡し、結果を受け取りページ内容を更新する。codeタグの内容取得と更新はDOM ElementのinnerHTMLプロパティで実装。
Rustでの構文解析とDOM更新は、時間の掛かる処理なので、1つのcodeタグを処理したらsetTimeoutを使って制御を他の処理に譲る。

色付けルールの選択

何も指定がなければ、指定されたものの中から 色が一番多く変わるものを調べて自動選択する。直接指定する場合は、codeタグのdata属性で指定する。<code data-rshl_lang=”rust”>
codeタグがない場合、wasmコード読み込みなどすべてキャンセルして何もしない。

Read more 続きを読む

以前は長いソースコードを表示する場合、ページ全体の高さを抑えるために表示高さを制限してスクロールバーを表示していましたが、マウスホイールやスワイプでの誤動作が気になっていました。解決策として、最初小さめ&スクロールバーなしで表示、(Read more)ボタンをクリックすると表示領域を拡大しスクロールバー表示、(Close)ボタンをクリックすると最初に戻るようにする。スクロールバーが出ない短いコードはそのまま表示。これで全体を見渡すときのスクロール誤爆がなくなるはず。Closeボタンについては快適な操作方法を模索中。

pub enum Token<'a> {
    // 上から解析
    Ret(&'a [char]),    // 改行 '\r\n' 'n' 'r' <br>
    Sp(&'a [char]),     // 空白文字列 ' ' '\t'
    // &???;            // HTMLエスケープ Chに変換
    Id(&'a [char]),     // 識別子 [_a-zA-Z][_a-zA-Z0-9]*
    Num(&'a [char]),    // 数字  [0-9]+
    NotA(&'a [char]),   // アスキー以外の文字列
    Ch(char),           // 1文字
    //SpCh(char),         // 空白1文字
}

impl<'a> Token<'a> {
    pub fn is_skip(&self, skip_ret : bool) -> bool {
        match self {
            Token::Ret(_s) => skip_ret,
            Token::Sp(_s)  => true,
            _ => false,
        }
    }

    pub fn len(&self) -> usize {
        match self {
            Token::Ret(s) 
            | Token::Sp(s)
            | Token::Id(s)
            | Token::Num(s)
            | Token::NotA(s) => s.len(),
            Token::Ch(_c) => 1,       
        }
    }
    pub fn to_string(&self) -> String {
        match self {
            Token::Ret(s) 
            | Token::Sp(s)
            | Token::Id(s)
            | Token::Num(s)
            | Token::NotA(s) => s.into_iter().collect(),
            Token::Ch(c) => c.to_string(),         
        }
    }
    pub fn to_vchar(&self) -> Vec<char> {
        match self {
            Token::Ret(s) 
            | Token::Sp(s)
            | Token::Id(s)
            | Token::Num(s)
            | Token::NotA(s) => Vec::from(*s),
            Token::Ch(c) => vec![*c],
            
        }
    }
    pub fn to_utf16(&self) -> Vec<u16> {
        match self {
            Token::Ret(s) 
            | Token::Sp(s) 
            | Token::Id(s)
            | Token::Num(s)
            | Token::NotA(s) =>  chars_to_utf16(s),
            Token::Ch(c) => char_to_utf16(*c),        
        }       
    }
}

const ESC_AMP : [u16;5] = [38,97,109,112,59];//"&amp;"
const ESC_LT : [u16;4] = [38,108,116,59];//"&lt;"; 
const ESC_GT : [u16;4] = [38,103,116,59];//"&gt;";
fn char_to_utf16(c : char) -> Vec<u16> {
    if c == '&' { return Vec::from(&ESC_AMP[..]);}
    if c == '<' { return Vec::from(&ESC_LT[..]);}
    if c == '>' { return Vec::from(&ESC_GT[..]);}
    let mut b = [0; 2];
    Vec::from(c.encode_utf16(&mut b))
}
fn chars_to_utf16(vc : &[char]) -> Vec<u16> {
    let mut b = [0; 2];
    let mut u = Vec::<u16>::new();
    for c in vc {
        u.extend_from_slice(c.encode_utf16(&mut b));
    }
    u
}

enum Esc{
    Lt,      // &lt; <
    Gt,      // &gt; >
    Amp,     // &amp; & 
    Unkwn,
}

pub struct Context<'ctx>{
    chars : &'ctx [char],
}
impl<'ctx> Context<'ctx> {
    pub fn new(chars : &'ctx [char]) -> Context {
        Context {
            chars : chars,
        }
    }
}

pub struct Reader<'read>{
    ctx : &'read Context<'read>,
    now : char,
    now_pos : usize,   
}

impl<'read> Reader<'read> {
    pub fn new(ctx : &'read Context) -> Reader<'read> {
        Reader{
            ctx : ctx,
            now : ' ',
            now_pos : 0,
        }
    }
    fn is_term(&self) -> bool {
        self.now_pos >= self.ctx.chars.len()
    }
    fn next(&mut self) -> Option<char> {
        if self.is_term() { return None;}
        self.now = self.ctx.chars[self.now_pos];
        self.now_pos += 1;
        return Some(self.now);
    }
    fn getback(&mut self){
        self.now_pos -= 1;
    }
    fn now(&self) -> char {
        self.now
    }

    fn sub_start(&self) -> usize {
        if self.now_pos == 0 { 0 }
        else { self.now_pos - 1 }
    }

    fn sub_to_now(&self, start : usize) -> &'read [char] {
        &self.ctx.chars[start..self.now_pos]
    }

}

pub struct Lexer {
    pub line_no : usize,
    pub html_mode : bool,
}

impl Lexer {
    pub fn new(html_mode : bool) -> Lexer {
        Lexer{
            line_no : 1,
            html_mode : html_mode,
        }
    }

    // コメント処理なし 次のphaseで
    pub fn parse<'a>(&mut self, read : &mut Reader<'a>)
    -> Vec<Token<'a>> {
        let mut tks = Vec::<Token>::new();
        while let Some(tk) = self.lex(read) {
            tks.push(tk);
        }
        return tks;
    }
    
    fn is_space(ch : char) -> bool {
        ch == ' ' || ch == '\t'
    }
    fn is_id_start(ch : char) -> bool {
        ch.is_ascii_alphabetic() || ch =='_'
    }
    fn is_id_ch(ch : char) -> bool {
        ch.is_ascii_alphanumeric() || ch =='_'
    }
    fn lex<'a>(&mut self, read : &mut Reader<'a>) -> Option<Token<'a>> {
        while let Some(ch) = read.next() {
            if ch == '\n' || ch == '\r' {
                return Some(self.crlf(read));
            }else if Self::is_space(ch) {
                return Some(self.space(read));
            }else if self.html_mode && ch == '&' {
                if let Some(tk) = self.escape(read) {
                    return Some(tk);
                }
            }else if self.html_mode && ch == '<' {
                if let Some(tk) = self.tag(read){
                    return Some(tk);
                }
            }else if self.html_mode && ch == '>' {
                //不正タグ 処理終了
                return None;
            }else if Self::is_id_start(ch){
                return self.identifier(read);
            }else if ch.is_ascii_digit() {
                return self.digit(read);    
            }else if !ch.is_ascii() {
                return self.none_ascii(read);
            }else{
                return Some(Token::Ch(ch))
            }
        }
        None
    }

    fn none_ascii<'a>(&mut self, read : &mut Reader<'a>)
    -> Option<Token<'a>> { 
        let start = read.sub_start();
        while let Some(ch) = read.next() {
            if ch.is_ascii() {
                read.getback();
                break;
            }
        }
        Some(Token::NotA(read.sub_to_now(start)))
    }
    fn digit<'a>(&mut self, read : &mut Reader<'a>)
    -> Option<Token<'a>> { 
        let start = read.sub_start();
        
        while let Some(ch) = read.next() {
            if !ch.is_ascii_digit() {
                read.getback();
                break;               
            }
        }
        Some(Token::Num(read.sub_to_now(start)))
    }


    fn identifier<'a>(&mut self, read : &mut Reader<'a>)
    -> Option<Token<'a>> {
        let start = read.sub_start();
        while let Some(ch) = read.next() {
            if !Self::is_id_ch(ch) {
                read.getback();
                break;
            }
        }
        Some(Token::Id(read.sub_to_now(start)))
    }
    fn tag<'a>(&mut self, read : &mut Reader<'a>)
    -> Option<Token<'a>> {
        let start = read.sub_start();
        let mut tn = String::new();
        while let Some(ch) = read.next() {
            if Self::is_space(ch) { read.getback();break; }
            if ch == '>' { break; }
            tn.push(ch);
        }
        if read.now() != '>' {
                while let Some(ch) = read.next() {
                    if ch == '>' { break; }
            }
        }
    
        if tn == "br" || tn == "br/" {
            self.line_no += 1;
            Some(Token::Ret(read.sub_to_now(start)))
        }else{ 
            None// <br>以外は削除
        }
    }

    fn esc_next<'a>(&mut self, read : &mut Reader<'a>) -> Esc {
        let mut r = String::new();
        r.push('&');
        while let Some(ch) = read.next() {
            r.push(ch);
            if ch == ';' {break; }
        }
        let r = r.to_lowercase();
        if r == "&amp;" { Esc::Amp }
        else if r == "&lt;" { Esc::Lt }
        else if r == "&gt;" { Esc::Gt }
        else { Esc::Unkwn }
    }

    fn escape<'a>(&mut self, read : &mut Reader<'a>)
    -> Option<Token<'a>> {
        let esc = self.esc_next(read);
        match esc {
            Esc::Amp => Some(Token::Ch('&')),
            Esc::Lt => Some(Token::Ch('<')),
            Esc::Gt => Some(Token::Ch('>')),
            _ => None,
        }
    }
    
    fn space<'a>(&mut self, read : &mut Reader<'a>) ->  Token<'a> {
        let start = read.sub_start();
        while let Some(ch) = read.next() {
            if !Self::is_space(ch) {
                read.getback();
                break;
            } 
        }

        Token::Sp(read.sub_to_now(start))

    }

    fn crlf<'a>(&mut self, read : &mut Reader<'a>) ->  Token<'a> {
        let start = read.sub_start();
        
        let ch = read.now();
        if ch == '\r' {
            if let Some(ch) = read.next() {
                if ch != '\n' { 
                    read.getback();//CR
                }//else CRLF
            }//else // CR[eof]
        }//else LF
        self.line_no += 1;
        Token::Ret(read.sub_to_now(start))
    }
}

ソースコード抜粋

今回作成したソースコードから重要、便利なものをいくつか紹介します。

JS preタグ以下のcodeタグの取得、書き換え

getElementsByTagNameを使用して指定したタグのDOMノード(HTMLElement)を取得します。対象が複数となるため配列が返ってきます。内容の書き換えは、ノードのinnerHTMLを使えば、ノード操作なしに内容を取得、更新できます。 取得(get)した場合、DOMノードをHTML文字列に変換し、書き込み(set)すると、逆に HTML文字列 をDOMノードに変換してくれます。ノード数が多いと結構時間がかかります。なので実際はsetTomeoutを使って、処理を分散するようにしてます。

// preタグのノード取得(配列)
let pres = document.getElementsByTagName('pre');
for(let p of pres) {//Edge17ではエラー 
    let codes = p.getElementsByTagName('code');
    for(let c of codes) {
        // codeノード以下をHTMLとして取得
        let html_str = c.innerHTML;

        // ハイライト処理 <span>タグ追加
        let hilite_str = ハイライト処理();

        // codeノードに書き戻し HTMLをノードに変換
        c.innerHTML = hilite_str;
    }
}

ページ読み込み後に処理開始

方法を調べるとonloadイベントで、という説明が多いのですが、今回は画像読み込みを待つ必要がなく、HTMLテキストが読み込まれた時点で実行可能なので、 DOMContentLoadedイベントを使用しました。 DOMの読み込み完了後にスクリプトを実行するとDOMContentLoadedイベントが発生しません。その場合 document.readyState で状態を調べ、 “loading” (読み込み中)ならイベント登録、終了していれば即時処理を実行するようにします。

document.addEventListener( 'DOMContentLoaded',
     function() {
        //DOMの読み込み完了時に呼ばれる
        //初期化処理
     }
);

// DOM読み込み状況確認
if(document.readyState == "loading"){
  document.addEventListener('DOMContentLoaded', function() {
    //DOMの読み込み完了時に呼ばれる
    //初期化処理
  });
}else{
  // すでに読み込み済み DOMContentLoadedが発生しない
  //初期化処理
}

type=”module”でのJavaScript読み込み

JavaScriptのtypeをmoduleにするとimportなどのモジュール機能が使用可能になります。これでグローバル変数名の衝突を気にする必要がなくなります。新しい機能なので古いブラウザでは使えませんが、使えないような古いブラウザはwasmにも対応していないので問題なし(?)。
機能demoでは以下のようなhtmlと起動用JavaScriptを使って、ハイライト処理を行っています。

index.html<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">

    <link rel="stylesheet" type="text/css" href="./index.css" />
    <link rel="stylesheet" type="text/css" href="./rshighlite/rshighlite.css" />
    
    <!-- 起動用jsの読み込みと実行 -->
    <script type='module' src = "./rshl_open.js"></script>
   
    <title>rshighlite</title>
  </head>
  <body>

<pre><code>
ソースコード
</code></pre>

  </body>
</html>
rshl_open.js// ハイライト処理のインポート
import * as rshighlite from "./rshighlite/rshighlite.js";

let root_path = "./rshighlite";

// 設定
let config = {
  code_filter : function(code){
    if("rshl_lang" in code.dataset){
      if(code.dataset.rshl_lang == "nohl"){
        return false;//処理しない
      }
    }
    return true;
  },
  wasm_file : root_path + "/rshighlite.wasm",

  default_rule : {
      name : "default",
      path : root_path + "/default.rshl",
  },

  // ファイルを1つにまとめる&バイナリー化予定
  // メインルール
  lang_rules : [
    {
      name :  "cpp",
      alias : ["c", "c++", ],
      path : root_path + "/cpp.rshl",
    },
    {
      name :  "rust",
      alias : ["rs", ],
      path : root_path + "/rust.rshl",      
    },
    {
      name :  "js",
      alias : ["javascript", ],
      path : root_path + "/javascript.rshl",      
    },
  ],

  // サブルール 自動選択対象外
  // <code data-lang="lang">があり、メインになければ読み込み
  sub_lang_rules : [
    { name :  "bat",
      alias : ["cmd",],
      path : root_path + "/bat.rshl",
      css : {
        code : "rshl-window-bat",
      }    
    },
    { name :  "toml",
      alias : [],
      path : root_path + "/toml.rshl",
    },
    { name :  "html",
      alias : [],
      path : root_path + "/html.rshl",
    },
  ],

};

//初期化
rshighlite.Init(config);

//実行
rshighlite.Cook();

WordPressでtype=”module”

WordPressでJavaScriptを読み込む場合、functions.phpで読み込むファイルを登録しますが、そのままだとtype=”text/javascript”になりモジュール機能を使うとエラーになります。typeをmoduleにするには、同じくfunnctions.phpにフィルター処理を追加してスクリプトタグの内容を差し替えます。子テーマの使用をお勧めします。

function.phpに追加function rshighlite_load() {
    if ( is_singular() ) {
        wp_enqueue_style( 'rshighlite-style',
            get_stylesheet_directory_uri() . '/rshighlite/rshighlite.css' );
        wp_enqueue_script( 'rshighlite-script',
            get_stylesheet_directory_uri() . '/rshl_open.js', array(), null, true );
    }
}

add_action( 'wp_enqueue_scripts', 'rshighlite_load' );

add_filter( 'script_loader_tag', 'change_to_type_module', 10, 3 );
function change_to_type_module( $tag, $handle, $src ) {
  if ( 'rshighlite-script' === $handle ) {
    $tag = '<script  type="module" src="' . esc_url( $src ) . '"></script>';
  }
  return $tag;
}

構文木と構文解析

当初の予定は、コメントや文字列の書式指定、特定キーワードの色付けなど簡単な仕様にするつもりでしたが、だんだん欲が出てきて汎用性があるものに作り変えました。boost spiritの悪夢(コンパイルが遅い、実行速度はもっと遅い)を以前体験したので、ライブラリは使わず自作、下記のような構文木を定義するスクリプトができました。大変でしたが良いものに仕上がったと思います。 EBNF(拡張バッカス・ナウア記法)の機能制限と拡張と表現を変えたものといっていいのか?
構文解析は、構文木のノードに文字列(spanのclass名=色)を割り当て、字句がどのノードに対応するか解析することでソースコードの色付けを行う仕様です。 ノードは自己参照、循環参照ができるので再帰処理も可能です。言語仕様を正確に記述すると処理が重くなるし大変なので、ハイライト処理に特化した緩い文法にしています。

htmlハイライト構文木(作成中)ident = Seq(
    AnyId(hl_class),
    M0_( Seq(
       Ch(hl_class,'-'), 
       AnyId(hl_class),
    )),
);
skp0_ = SKP0_(nohl);
skp1_ = SKP1_(nohl);

ident_tag = Seq(
    AnyId(hl_kwd),
    M0_( Seq(
       Ch(hl_kwd,'-'), 
       AnyId(hl_kwd),
    )),
);

tag = Seq(
    Ch(nohl, '<'),
    M0_1( Or(
         Ch(nohl, '/'), Ch(nohl, '!'),
    ) ),
    skp0_,
    ident_tag,
    M0_( Seq(
        skp1_, ident,
        skp0_, Ch(nohl, '='), skp0_,
        Or(qout_string, apos_string )
    )),
    skp0_,
    M0_1(Ch(nohl, '/')),
    Ch(nohl, '>'),
);

doctype = Seq(
    Chs(nohl, "<!"),
    AnyId(hl_kwd),
    M0_( Seq (
        skp1_,
        Or( ident, qout_string, apos_string ),
    ) ),
    skp0_,
    Ch(nohl, '>'),
);

html_comment = FROM_TO(
    hl_cmnt,
    Chs(hl_cmnt, "<!--"),
    Chs(hl_cmnt, "-->"),
);

escape = Seq(
    Ch(hl_kwd,'&'),
    AnyId(hl_kwd),
    Ch(hl_kwd,';'),
);

ROOT = IF_ELSE(
    IF( AnyCh(nohl), Or(
        doctype,
        tag,
        html_comment,
        escape,
        qout_string, apos_string,
    ),),
    IF( AnyNum(nohl), loose_number ),
);

ダウンロード

Rustのソースコードがまだ中途半端なので、機能デモの内容をまとめたファイル(Javascriptとwasmバイナリ)だけの公開です。

“rshighlite ソースコードのハイライト表示プラグインデモ” をダウンロード rshighlite_demo.zip – 10 回のダウンロード – 60 KB

今後の目標

GitHub
動作確認とソースコードの整理をしてから正式な公開

構文木のスクリプトをバイナリ化
コンパイルした結果を読み込みことで、高速化とコンパイル関連の削除によるコードサイズ削減。wasmファイル70~80kbを目標。あとスクリプトファイルが増えてきたのでバイナリ化して1つにまとめる。

構文木と構文解析のクレート作成、公開
もう少し改良してからRustのクレートとして公開。

WebGLのほうも進めたいのでこのサイトで動作確認しながら、余裕ができたら取り掛かる予定です。

コメントを残す

メールアドレスが公開されることはありません。

認証:数字を入力してください(必須) * Time limit is exhausted. Please reload CAPTCHA.