SATySFi上で構文定義可能なシンタックスハイライト(の宣伝)

これはSATySFi Advent Calendar 2019の11日目の記事です。

(10日目: puripuri2100さん、12日目: na4zagin3さん

概要

SATySFiでコードブロックのシンタックスハイライトができるパッケージ群satysfi-highlightを作成しましたので紹介します。

https://github.com/Amaoto17/satysfi-highlight

このパッケージは外部のツールを使わず、ハイライトルールの定義から色付けまでをすべてSATySFi上で完結させることを目標として開発しました。

サンプル文書を試す

satysfi-highlightのリポジトリにはOCamlコードのハイライトを行うサンプル文書 (sample.saty)が同梱されています。試しにpdfを出力してみましょう。

satysfi -b sample.saty -o sample.pdf

-bまたは--bytecompでバイトコンパイルを有効にすると速度が3~4倍ほど改善します。使用時はこれらのオプションを有効にすることを推奨します。

以下のようなpdfが出力されます。割といい感じで色付けされているのではないでしょうか。

f:id:amaoto017:20191211002618p:plain

使い方

highlight/ディレクトリをSATySFiが読み込める場所に配置してください。以下では@requireで読み込み可能な位置にディレクトリが配置されていることを仮定して説明を行いますが、お使いの環境に応じて読み替えてください。

コードをハイライトする

@require: highlight/code
@require: highlight/lang/ocaml

...
  +code?:(OCaml.rule)(```
    let () = print_endline "hello, world!"
  ```);
...

ハイライトはブロックコマンド+codeによって行います。+codeにオプション引数でハイライトルールを与えることで設定に応じた色付けをすることが可能です。

実用上は特定の言語が文書中で繰り返し使われることが多いかと思います。オプション引数を書くのが面倒くさい場合にはプリアンブルでコマンドを定義してしまうとよいでしょう。

let +code-caml s = '<
  +code?:(OCaml.rule)(s);
> in
...

構文を定義する

構文はSATySFi上のDSLで記述します。

はじめに

satysfi-highlightによる解析は行単位で動作し、指定されたハイライトルールを文字列の先頭から繰り返し適用し、マッチした文字列を消費していく形で動作します。パーサーコンビネータを使ったことがある方はそれと同じイメージを持っていただけると分かりやすいと思います(実際に解析にはパーサーコンビネータを用いています)。

構文の定義を行うにはhighlight/lib/syntax.satyhhighlight/style.satyhをインポートする必要があります。open Syntax名前空間を開いておくと楽です。

基本的なルール

基本的なルールはpattern rere正規表現)で記述します。

let rule-boolean =
  pattern `\b(true|false)\b`

これのみだと入力文字列が消費されるだけで色付けはされません。色を指定するにはcaptureを使います。

let rule-boolean =
  pattern `\b(true|false)\b`
    |> capture 0 (style ReservedConstant)

capture n (style style-name)の形で、n番目のグループにマッチした文字列のスタイルとしてstyle-nameを指定します。nは0番がマッチした文字列全体、1以降が正規表現内で左からn番目の開き括弧のグループにマッチした文字列に対応します。style-nameに指定できる値はhighlight/style.satyhにおいてstyle型として定義されています。

style

現在のstyle型の内容は以下の通りです。

type style =
% comments
  | Comment            % 下記以外のコメント
  | LineComment        % 行コメント
  | BlockComment       % ブロックコメント
  | Documentation      % ドキュメント
% constants
  | Constant           % 下記以外の定数
  | NumericConstant    % 数値
  | CharacterConstant  % 文字
  | ReservedConstant   % 言語で予約されている定数(true など)
% entities
  | FunctionName       % 関数名
  | TypeName           % 型名
  | ClassName          % クラス名
  | ModuleName         % モジュール名
% storage modifiers
  | StorageModifier    % 記憶域クラス(static など)
% keywords
  | Keyword            % キーワード
  | ControlKeyword     % 制御構文に用いるキーワード(while など)
  | Operator           % 演算子
  | Directive          % ディレクティブ
% string
  | String             % 文字列
  | Regexp             % 正規表現
% variables
  | Variable           % 変数
  | Parameter          % 仮引数
  | ReservedVariable   % 言語で予約されている変数名(this など)

TextMate命名規則を参考にして書いていますが、適当な区分なので、今後、名称の変更や要素の増減が行われる可能性があります。

正規表現

正規表現は以下の機能をサポートしています(詳しい説明は省略)。

正規表現
特定の1文字 a
連接 ab
選択 a|b
0回以上の繰り返し a*
1回以上の繰り返し a+
0回または1回 a?
n回の繰り返し a{n}
n回以上の繰り返し a{n,}
n回以下の繰り返し a{,n}
m回以上n回以下の繰り返し a{m,n}
メタ文字のエスケープ \*, \\
任意の1文字 .
行頭/行末 ^, $
単語/非単語境界 \b, \B
単語/非単語構成文字 \w, \W
10進/非10進文字 \d, \D
16進/非16進文字 \h, \H
空白/非空白文字 \s, \S
文字クラス(いずれか1文字) [abc]
文字クラス(否定) [^abc]
文字クラス(範囲指定) [a-z]
文字クラス(共通部分) [a-d&&[^b]]
グループ化 (ab)
グループ化(キャプチャなし) (?:ab)
後方参照 \1
アトミックグループ (?>ab)
肯定/否定先読み (?=a), (?!a)
肯定/否定後読み (?>=a), (?>!a)
最大量指定子 a*?, a+?, a??
最小量指定子 a*+, a++, a?+

正規表現中で特殊な意味を持つ文字(メタ文字)は、通常の文字として扱いたい場合エスケープが必要です。通常は|?*+()[]^$.\の各文字がメタ文字です。文字クラス内では[]&\の各文字に加え、-^が位置によって特殊な意味を持ちます。

スタイルの優先順位

ある部分文字列に複数のスタイルが適用されうる場合、範囲が狭いものが優先されます。

pattern `123(+)45`
  |> capture 0 (style NumericConstant)
  |> capture 1 (style Operator)

上のルールでは、12345にはNumericConstant+にはOperatorが適用されます。+は文字列全体と1番目のグループの両方に属しますが、1番目のグループのスタイルが優先されます。

複数行にまたがるルール

patternでは1行の色付けしか行えませんでした。しかし、大抵の言語にはブロックコメントなど、複数行に渡って色付けを行いたい構文要素が存在します。そのような要素を色付けするときはpattern-blockを使います。

% /* から */ までの文字列全体に Comment を適用する例
let rule-comment =
  pattern-block (|
    enter = pattern `/\*`
  ; leave = pattern `\*/`
  ; style = Some (Comment)
  ; inner = []
  |)

引数として以下の4つのフィールドを持つレコードをとります。

  • enter : ブロックの開始を表すルール
  • leave : ブロックの終了を表すルール
  • style : ブロック全体に適用されるスタイル
  • inner : ブロック内部で適用されるルールのリスト

pattern-blockの動作は概ね以下のようになっています。

  • enterがマッチするとブロックルールの解析に入る。
  • ブロックの解析中にleaveがマッチすると解析から抜ける。
  • 解析中にinner内のルールがマッチした場合、ルールに従って色付けを行う。
  • leave, innerのどちらにもマッチしなければ文字を飛ばす。

複数のルール

複数のルールをまとめたいときはpatternsを使います。

let rule =
  patterns [
    pattern `ab`
  ; patterns [
      pattern `cd`
    ; pattern `ef`
    ]
  ]

リスト内のルールは先頭から順に試行されます。patternsの中にpatterns入れ子にすることもできます。

再帰的なルール

ルール内に定義中のルール自身が現れるようなルールが書きたいときがあります。以下のようなケースです:

% OCamlのネスト可能コメント(コンパイルエラー)
let-rec rule-comment =
  pattern-block (|
    enter = pattern `\(\*`
  ; leave = pattern `\*\)`
  ; style = Some (Comment)
  ; inner = [rule-comment]
  |)

実際には上のコードはコンパイルできません。このような再帰的なルールを書くときはfixを使います。

let rule-comment = fix (fun rule-comment -> (
  pattern-block (|
    enter = pattern `\(\*`
  ; leave = pattern `\*\)`
  ; style = Some (Comment)
  ; inner = [rule-comment]
  |)
))

fixOCamlのパーサーコンビネータライブラリ Angstrom1 を参考にしたもので、fix (fun f -> P)という形で使います。Pの中でfを呼び出すと、P全体が再帰的に呼び出されます。上のコードでは、inner内のrule-commentの呼び出しが先頭のpattern-block自体の呼び出しになります。

その他の関数

capturesで複数のcaptureをまとめて書けます。

pattern `123(+)45`
  |> captures [
    (0, style NumericConstant)
  ; (1, style Operator)
  ]

keywords [keyword1; ...; keywordN]pattern `\b(keyword1|...|keywordN)\b`と同等です。

keywords [`true`; `false`]
% => pattern `\b(true|false)\b`と同等

入力の読み飛ばし

前述した通り、解析は行の先頭から順に行われます。ある位置から解析を行ってルール内のどのパターンにもマッチしなかった場合は、その位置の文字に応じて以下のパターンのいずれかで文字列を読み飛ばします。

  • 任意長の空白
  • 1文字以上の単語構成文字、およびその後の任意長の空白
  • 任意の1文字

より細かく制御したい場合、今のところ専用の機能は用意していませんが、ルールの末尾に必ず文字列を消費して成功するパターンを入れておくなどの対処法が(一応)あります。

let rule =
  patterns [
    ...
  ; pattern `.`  % 常に1文字単位で読み飛ばす
  ]

正規表現のキャッシュ

patternは受け取った正規表現をパーサーコンビネータに変換しています。fix再帰的なルールを書く場合、評価のたびに変換が行われるとパフォーマンスが悪化するため、正規表現のキャッシュを行っています。……ただし、現状SATySFiでは文字列をキーとする二分探索木やハッシュテーブルが書けないため、以下を見てわかる通り非常に雑な実装です。

let-mutable cache <- fun _ -> None

let add-cache str re =
  let f =
    let g = !cache in
    fun s -> (if string-same s str then Some re else g s)
  in
  cache <- f

let find-cache str =
  let g = !cache in g str

色の変更

highlight/color-scheme.satyh内のcolor-of関数を書き換えることで色を変更できます。color-ofstyle型の値を受け取ってcolor型の値を返す関数です。例えば、Keywordに対応する色を変更したければ、color-ofKeywordに対して返す色を変更すればOKです。

簡単な記述例

簡単な例として、整数の四則演算に対するハイライトルールを以下に示します。

@require: highlight/lib/syntax
@require: highlight/style

open Syntax

module Arithmetic : sig

  val rule : rule

end = struct

  let rule = fix (fun rule -> (
    let rule-number =
      pattern `\d+`
        |> capture 0 (style NumericConstant)
    in

    let rule-operator =
      pattern `[-+*/]`
        |> capture 0 (style Operator)
    in

    let rule-paren =
      pattern-block (|
        enter = pattern `\(`
          |> capture 0 (style Comment)
      ; leave = pattern `\)`
          |> capture 0 (style Comment)
      ; style = None
      ; inner = [rule]
      |)
    in

    patterns [
      rule-number
    ; rule-operator
    ; rule-paren
    ]
  ))

end
  • rulerule-number, rule-operator, rule-parenのいずれかである
  • rule-numberは1文字以上の10進整数にマッチする。スタイルはNumericConstant
  • rule-operator-+*/のいずれかにマッチする。スタイルはOperator
  • rule-paren(で始まって)で終わる。両側の括弧のスタイルはComment
  • rule-parenの括弧の中でrule自身を再帰的に呼び出す

出力

f:id:amaoto017:20191211003539p:plain

おわりに

satysfi-highlightによるシンタックスハイライトの紹介でした。SATySFi Advent Calendarの他の記事もまだお読みになっていない方は是非。