FIVETEESIXONE

NOIR にシンタックスハイライトの種類を追加する


はい、えー Crystal Advent Calendar 2017 の4日目は「NOIR にシンタックスハイライトの種類を追加する」です。

といきなり言っても何のことかわからないと思うので、まずこの NOIR というものについて簡単に紹介します。

NOIR

NOIR というのは、Crystal のハードコアなコントリビューターでもある MakeNowJust 氏による syntax highlighter で、Ruby の Rouge という同様の gem の Crystal への porting です。

スタンダールの「赤と黒 (Le Rouge et le Noir)」に擬えての rouge に対する noir ってことで洒落てますね。

Rogue と同様、ライブラリとして require して使う以外にも etnoir という CLI が用意されておりすぐに使えるようになっています。また、実際にシンタックス定義もかなり Rouge と共通しており、規則ファイルの porting もしやすくなっているようです。本当にそうなのかは実際に新しい syntax 定義を作ってみるところで明らかになると思います (まだこれを書いてる時点では作ってないのでドキドキ) 。

後は作者自身によるこちらのスライドをご覧いただければと思います。

https://speakerdeck.com/makenowjust/le-noir-que-jai-cree

NOIR にシンタックスを追加しよう

現時点で、NOIR がサポートする syntax は以下です。

  • Crystal
  • JavaScript
  • Python
  • Ruby

これだけ。まだまだ多くはありません。

今日はここにですね。

  • CSS の syntax highlight を追加してみたいと〜思います!

なぜ CSS にしたのか

ほんとは、Crystal のテンプレートエンジンのファイルである ECR にしようとしたんです。しかしですね、

  • Rouge では ERB とかのテンプレートは TemplateLexer を継承している (あっっっこれも作ったりしないといけないかな…)
  • ECRHTML の syntax を embed することになりそう (うっっっこれも作らないといけないのか…)
  • HTMLJavaScript と CSS を embed することになりそう (JS はすでにあるが、CSS? 知らない子ですね…)

ということで「よしまずは CSS を作ろう!」となったわけであります。

作った

作ってみました。

結論から言うと、Rouge の syntax をかなり容易に porting することができました。

やったことは、

  • Rouge の syntax ファイルをそのままコピペ
  • コンパイルエラーになった箇所などを修正
  • spec を書く

くらいでできあがりです。

CSS の規則自体がそんなに長くなく複雑でもなかったこともあるとは思いますが、初めてでも作るだけなら30分あれば十分に porting することができました。

修正も、既存の syntax 定義の規則を参考にすれば「あーこんな感じに直すのね」とすぐわかります。まあこれはそのまま (Rogue 版と NOIR 版の) diff を見てもらうのが手っ取り早そうですのでそのまま貼ります。

--- css.rb
+++ src/noir/lexers/css.cr
@@ -1,20 +1,14 @@
-# -*- coding: utf-8 -*- #
+require "../lexer"

-module Rouge
-  module Lexers
-    class CSS < RegexLexer
-      title "CSS"
-      desc "Cascading Style Sheets, used to style web pages"
-
-      tag 'css'
-      filenames '*.css'
-      mimetypes 'text/css'
+class Noir::Lexers::CSS < Noir::Lexer
+  tag "css"
+  filenames %w(*.css)
+  mimetypes %w(text/css)

-      identifier = /[a-zA-Z0-9_-]+/
-      number = /-?(?:[0-9]+(\.[0-9]+)?|\.[0-9]+)/
+  IDENTIFIER = /[a-zA-Z0-9_-]+/
+  NUMBER = /-?(?:[0-9]+(\.[0-9]+)?|\.[0-9]+)/

-      def self.attributes
-        @attributes ||= Set.new %w(
+  ATTRIBUTES = Set.new %w(
           align-content align-items align-self alignment-adjust
           alignment-baseline all anchor-point animation
           animation-delay animation-direction animation-duration
@@ -112,10 +106,8 @@
           volume white-space widows width word-break
           word-spacing word-wrap writing-mode z-index
         )
-      end

-      def self.builtins
-        @builtins ||= Set.new %w(
+  BUILTINS = Set.new %w(
           above absolute always armenian aural auto avoid left bottom
           baseline behind below bidi-override blink block bold bolder
           both bottom capitalize center center-left center-right circle
@@ -143,10 +135,8 @@
           uppercase url visible w-resize wait wider x-fast x-high x-large
           x-loud x-low x-small x-soft xx-large xx-small yes
         )
-      end

-      def self.constants
-        @constants ||= Set.new %w(
+  CONSTANTS = Set.new %w(
           indigo gold firebrick indianred yellow darkolivegreen
           darkseagreen mediumvioletred mediumorchid chartreuse
           mediumslateblue black springgreen crimson lightsalmon brown
@@ -172,24 +162,21 @@
           hotpink lightyellow lavenderblush linen mediumaquamarine green
           blueviolet peachpuff
         )
-      end

       # source: http://www.w3.org/TR/CSS21/syndata.html#vendor-keyword-history
-      def self.vendor_prefixes
-        @vendor_prefixes ||= Set.new %w(
+  VENDOR_PREFIXES = Set.new %w(
           -ah- -atsc- -hp- -khtml- -moz- -ms- -o- -rim- -ro- -tc- -wap-
           -webkit- -xv- mso- prince-
         )
-      end

       state :root do
         mixin :basics
         rule /{/, Punctuation, :stanza
-        rule /:[:]?#{identifier}/, Name::Decorator
-        rule /\.#{identifier}/, Name::Class
-        rule /##{identifier}/, Name::Function
-        rule /@#{identifier}/, Keyword, :at_rule
-        rule identifier, Name::Tag
+    rule /:[:]?#{IDENTIFIER}/, Name::Decorator
+    rule /\.#{IDENTIFIER}/, Name::Class
+    rule /##{IDENTIFIER}/, Name::Function
+    rule /@#{IDENTIFIER}/, Keyword, :at_rule
+    rule IDENTIFIER, Name::Tag
         rule %r([~^*!%&\[\]()<>|+=@:;,./?-]), Operator
         rule /"(\\\\|\\"|[^"])*"/, Str::Single
         rule /'(\\\\|\\'|[^'])*'/, Str::Double
@@ -199,25 +186,25 @@
         mixin :basics
         rule /url\(.*?\)/, Str::Other
         rule /#[0-9a-f]{1,6}/i, Num # colors
-        rule /#{number}(?:%|(?:em|px|pt|pc|in|mm|cm|ex|rem|ch|vw|vh|vmin|vmax|dpi|dpcm|dppx|deg|grad|rad|turn|s|ms|Hz|kHz)\b)?/, Num
+    rule /#{NUMBER}(?:%|(?:em|px|pt|pc|in|mm|cm|ex|rem|ch|vw|vh|vmin|vmax|dpi|dpcm|dppx|deg|grad|rad|turn|s|ms|Hz|kHz)\b)?/, Num
         rule /[\[\]():\/.,]/, Punctuation
         rule /"(\\\\|\\"|[^"])*"/, Str::Single
         rule /'(\\\\|\\'|[^'])*'/, Str::Double
-        rule(identifier) do |m|
-          if self.class.constants.include? m[0]
-            token Name::Constant
-          elsif self.class.builtins.include? m[0]
-            token Name::Builtin
+    rule IDENTIFIER do |m|
+      if CONSTANTS.includes? m[0]
+        m.token Name::Constant
+      elsif BUILTINS.includes? m[0]
+        m.token Name::Builtin
           else
-            token Name
+        m.token Name
           end
         end
       end

       state :at_rule do
-        rule /{(?=\s*#{identifier}\s*:)/m, Punctuation, :at_stanza
+    rule /{(?=\s*#{IDENTIFIER}\s*:)/m, Punctuation, :at_stanza
         rule /{/, Punctuation, :at_body
-        rule /;/, Punctuation, :pop!
+    rule(/;/, Punctuation, :pop!)
         mixin :value
       end

@@ -232,9 +219,9 @@
       end

       state :at_content do
-        rule /}/ do
-          token Punctuation
-          pop! 2
+    rule /}/ do |m|
+      m.token Punctuation
+      m.pop! 2
         end
       end

@@ -246,28 +233,28 @@
       state :stanza do
         mixin :basics
         rule /}/, Punctuation, :pop!
-        rule /(#{identifier})(\s*)(:)/m do |m|
-          name_tok = if self.class.attributes.include? m[1]
+    rule /(#{IDENTIFIER})(\s*)(:)/m do |m|
+      name_tok = if ATTRIBUTES.includes? m[1]
             Name::Label
-          elsif self.class.vendor_prefixes.any? { |p| m[1].start_with?(p) }
+                 elsif VENDOR_PREFIXES.any? { |p| m[1].starts_with?(p) }
             Name::Label
           else
             Name::Property
           end

-          groups name_tok, Text, Punctuation
+      m.groups name_tok, Text, Punctuation

-          push :stanza_value
+      m.push :stanza_value
         end
       end

       state :stanza_value do
-        rule /;/, Punctuation, :pop!
-        rule(/(?=})/) { pop! }
+    rule(/;/, Punctuation, :pop!)
+    rule(/(?=})/) do |m|
+      m.pop!
+    end
         rule /!important\b/, Comment::Preproc
         rule /^@.*?$/, Comment::Preproc
         mixin :value
       end
     end
-  end
-end

変えたところはこんなもんです。

spec がちょっと特殊で、it_lexes_fixtures というメソッドでチェックするんですが、

  • input として highlight したい言語ファイル (*.in)
  • output としてそれを解析した結果のファイル (*.out)

を用意して突き合わせます。で、output はトークン列を inspect した文字列表現になるので、その形に合わせて用意する必要があります (SpecFormatter クラスを参照) 。

ただ、(今回自分は使いませんでしたが) UPDATE_FIXTURE という環境変数に 1 をセットすることで、解析結果を元に output ファイルを生成することができるようですので、手元で highlight を試して見た目に問題なさそうであれば、自動生成した後でざっと目視チェックすることで簡単に用意することもできそうです。

終わりに

ここで作った CSS Laxer は無事マージしてもらえて、めでたく1つ対応する言語が追加されました。

個人的には NOIR はすごくいいと思っていて、それは、

  • 使いやすくて高速な Static Site Generator が欲しい!!

という思いがあるからです。

Crystal ではこの辺のエコシステムはまだ途上ですが、NOIR を利用すれば Markdown のレンダリングエンジンに syntax highlight を組み込むことも容易ですし、そして、それは Crystal の言語のパワーによって Jekyll や Middleman のような、拡張しやすくて高速な static site generator が作りやすくなるんじゃないかと思うからです。

皆さんも、NOIR に好きな言語の syntax を追加してみてはいかがでしょうか。

これで4日目の記事はおしまいです。Crystal Advent Calendar 2017 明日の5日目は…えと、あれ?登録されてないぞ?誰か書いてくれないかな😢