はい、えー 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
を継承している (あっっっこれも作ったりしないといけないかな…) ECR
はHTML
の syntax を embed することになりそう (うっっっこれも作らないといけないのか…)HTML
はJavaScript
と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日目は…えと、あれ?登録されてないぞ?誰か書いてくれないかな😢