Ruby
asciidoctor
crystal
技術書典
技術書典5

技術書典5で頒布する予定の本のコードの動作チェックや、文章の校正をCIでするようにした話

TL;DR (概要)

  • Crystalのような破壊的変更がアクティブなプログラミング言語の本を作る場合、バージョンアップで本の内容が壊れることが よくある
  • この問題に対処するため、技術書典5頒布する予定の本では、サンプルコードが正しく動作することをCIで確認するようにした。
  • ソースコードのフォーマット忘れが無いかもチェックするようにした。
  • ↑のようなことができたのはAsciidoctorのソースコードをincludeする機能の力が大きい。
  • ついでにRedPenで文章の校正も行なうようにした。
  • 長期間に渡ってメンテナンスする予定の本であればこのような工夫するのは当然だし、そうでなくても本の品質を高める意味でこの工夫には価値があると思う。

はじめに(ポエム)

Crystal-JPというプログラミング言語Crystalの日本語ユーザーグループで、Crystalの普及に勤しんでいる、ということになっている『さっき作った』という者です。

Crystal-JPでは過去に三回、技術書典でCrystalに関する小ネタをまとめた同人誌を頒布してきたのですが、その頒布数は芳しくないものがありました。
この活動を主催しているボク自身のやる気が足りないのもその理由の一つに挙げられますが、それ以上に そもそもCrystalというプログラミング言語があまり知られていないのに、小ネタ集を初めから手に取ってくれる人なんて中々いない ということを感じました。
そこで、 技術書典4 に合わせて、Crystal-JPではCrystalの入門書になるような一冊を書こう、ということになったのです。

せっかく入門書を執筆するのであれば、長く使えるような本にしたい、と考えるのは当然の流れです。
また、今までの本もCrystalのバージョンアップに追い付いていけず、本の内容が現在のバージョンでは動作しなくなっていることが問題でした。
長く使えるようにするためには、バージョンアップに付いていけるように、壊れていないか動作チェックができるような仕組みが必要なことは確定的に明らかです。

というわけで、文章中のサンプルコードの動作チェックができるような仕組みを作って、さらにそれをCircleCIで実行するような環境を整備しました。
今年の一月くらいに
技術書典4 の当落が決まる前から行動していたんですね。偉い。

しかし、Crystal-JPは技術書典4に 敢え無く落選 したので、このシステムやこのために書かれた原稿は永らく放置されていました。
正直、技術書典4に応募したサークルの中で(応募時点で)一番やる気があったと思うので、かなりヘコみました。
一応原稿は集めて本になるようにはしたのですが、実際に印刷するところまで気が進まなかったのはこのせいです。

その後、技術書典5が開催される運びとなり、どうも会場が広くなったらしくCrystal-JPも参加できることになりました。
そこでようやくこのシステムの真価が発揮されることとなったのです。

記事が最初に書かれたのは今年の二月か三月の辺りで、その頃のCrystalのバージョンは0.23.0くらいでした。
しかし十月現在の最新版のCrystalのバージョンは0.26.1です。
この間にもいくつか破壊的変更があり、実際に原稿のいくつかのサンプルコードがコンパイルエラーになっていたり、実行結果が変わっていたりしました。
それらの変化を的確に見つけることができたのは、こうしたシステムを整備したおかげだと思っています(自画自賛)。

<!-- これより広告 -->

Crystal-JPは技術書典5で「か62」に配置されています。
「Introducing Crystal Programming Language」というCrystalの初心者〜中級者向けの解説書を頒布する予定です。
142ページで1000円。お買い得だね。
構文だけじゃなくてWeb開発とかCLI開発とか具体例も乗ってる良い本です。よろしくお願いします。

https://techbookfest.org/event/tbf05/circle/25970003

また、印刷された本でなければ、Webで無料で読むことができます。
購入の前に一度目を通してみて、良かったら買うのもいいかもしれません。
(ちょっとスタイルが微妙で読みづらいかもしれません。誰か直してください‥‥)

https://crystal-jp.github.io/introducing-crystal/

Introducing Crystal Programming Languageの表紙

<!-- 広告終了 -->

さて、広告も終わったので本編です。

システム概観

「Introducing Crystal Programming Language」の原稿やソースコードは次のリポジトリにあります。

https://github.com/crystal-jp/introducing-crystal/

そして、コードの動作チェックを含めたシステムはこんな感じになってます。

  1. GitHubにコミットされると、
  2. CircleCIでサンプルコードの動作チェックやRedPenによる自動校正が実行されて、
  3. チェックが通ったらCrystal-JPのSlackに通知して、GitHub PagesにビルドしたWebページをpushする。

というのが大体の流れです。どうってことは無いのですががんばりました。

また、masterでのみGitHub Pagesへのデプロイが走るようにするためにCircleCIのworkflow機能を使ったりしました。
微妙にオーバーエンジニアリングな気もしますが、半分以上趣味なのでやりたいようにやれるのが技術系同人誌執筆のいいところかもしれません。

.circleci/config.yml

workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build:
          filters:
            branches:
              ignore: gh-pages
      - deploy:
          requires:
            - build
          filters:
            branches:
              only: master

サンプルコードの動作チェック

ソースコードの動作チェックを実装するに当たって、次の二つの事柄を強く考えていました。

  1. どうやってサンプルコードを原稿から取得するか。
  2. 動作チェックのためのアサーションはどのように記述するか。

これらについて説明していきます。

1. どうやってサンプルコードを原稿から取得するか。

最初はMarkdownのパーサーを使ってコードブロックの一覧を取得して、それらをファイルに保存して実行すればいいかと考えていたのですが、これだと原稿中のコードが部分だった場合に困るし、かと言って全てのコードブロックでソースコード全体を書くように強制するのも現実的ではないと感じたので、この方法は諦めることにしました。

そこで、発想を転換して、サンプルコードの完全なものは原稿のテキストファイルとは別のファイルに保存することにして、原稿からそれをincludeする、という方針を取ることにしました。
ここで問題になるのは原稿のフォーマットです。
Markdownを独自に拡張してそのような機能を追加したものや、あるいはreStructuredTextRE:VIEWを採用しても良かったのですが、調べたところAsciidoctorinclude機能が一番強力そうだったので、Asciidoctorを採用することにしました。

Asciidoctorのinclude機能には、次のような特徴があります。

  • ソースコードをファイルから読み込んで、コードブロックとして表示できる。
  • 加えて、Asciidoctorのファイルを読み込んで文書の一部にすることもできる。
  • ファイルの一部分だけを読み込むために、コメントとしてタグを埋め込んで、その範囲を指定することができる。
  • (includeの機能というかコードブロックの機能だけど)注釈をコードブロックの外に表示することができる。

かなり高機能なことが分かると思います。

具体例としては、次のようなCrystalのソースコードがあったとします。

# tag::decl[]
foo = true ? 1 : "foo"
# end::decl[]

# tag::body[]
# <1>
if foo.is_a?(Int32)
  # <2>
  puts "number"
else
  # <3>
  puts "string"
end
# end::body[]

察しの良い方なら既に気付いているかと思いますが、ソースコード中の # tag::# end:: から始まるコメントがタグです。

これをincludeするAsciidoctorのコードはこんな感じです。

まず、普通にincludeする例です。

[source,crystal]
----
include::./foo.cr[tags=decl]
----

この場合、# tag::decl[]から# end::decl[]までの範囲のみがincludeされて表示されることになります。

次に、注釈付きでincludeする例です。

[source,crystal]
----
include::./foo.cr[tags=body]
----
<1> この位置での`foo`の型は`Int32 | String`。
<2> この位置での`foo`の型は`Int32`。
<3> この位置での`foo`の型は`String`。

この場合、# tag::body[]から# end::body[]までの範囲が表示されて、さらに<1>から<3>までの注釈(callout)がいい感じに表示されます。

また、includeのパス指定が原稿ファイルの位置から相対座標で指定できるのは地味に便利で重要なところでした。

このタグで範囲を指定できるという特徴は個人的にはかなり重要でした。
というのも、もしコードの動作チェックを行う機能を実装したとしても、例えばコードの部分読み込みが行番号指定だったりすると、結果的に変更に弱いものになってしまいます。
バージョンが変わってコードが修正されたときに行番号を修正し忘れて表示がおかしくなったり、そもそもそういった事態を避けるために他の執筆者がコードの部分は原稿に直接書くようにしてしまったら元も子もありません。

他の軽量マークアップ言語にもこうしたinclude機能が実装されたらいいな、と思います。

(もしかしたらあるかもしれません‥‥。reSTにはあったような気がするけど、Crystalの本でPython or RubyならRubyを取ったような‥‥)

2. 動作チェックのためのアサーションはどのように記述するか。

動作チェックするためには、チェックされるコードと期待される実行結果をどこかに書かなければいけません。
そのための方法はいくつかあると思います。例えば、そのプログラミング言語の標準ライブラリのアサーションを使う、という方法です。

Crystalの場合はspecという標準ライブラリがあり、そこで様々なアサーションが定義されています。
ですが、それは過去のRSpecライクなfoo.should be_trueのような記法で、そのシンタックスを説明するだけで骨が折れます。
特にこの本は初心者もターゲットにしている本なので、そういった複雑な記法を最初から説明するのは得策と呼べません。

そこで、今回はコメントを使った方法を取ることにしました。
それらについて説明していきます。

コメント・タグを使ったアサーション

ソースコード中にコメントに// => 実行結果# => 実行結果のように書いて、その行の実行結果を例示することはよくある表記ではないかと思います。
今回は、基本的にはこの記法を使ってアサーションを行うことにしました。

具体的には、このようなコメントが現れたときに上のspecライブラリの記法に変換するようなフィルタをRubyで実装して、サンプルコードにそれを適用したものを実行するようにしました。
サンプルコードは各章の原稿があるディレクトリのexamplesディレクトリ以下に配置するという規則にして、そこにあるファイルを処理しています。
その実装は以下のファイルにあります。

script/example.rb

例えば、次のようなコードは、

1 + 2 # => 3
"foo" # => "foo"

[1, 2][3] # raises IndexError (Index out of bounds)

実際には次のようなコードに変換されてから実行されます。

require "spec"

it "foo.cr" do
  (1 + 2).inspect.should eq("3")
  ("foo").inspect.should eq("foo")

  expect_raises(IndexError, "Index out of bounds") { [1, 2][3] }  
end

読み手にも分かりやすくて、いい感じなのではないかと思います。

またCrystalは文法こそRubyに似ていますがRubyほど柔軟な言語ではないので、トップレベルやクラス・モジュール定義以外でメソッドを定義するとエラーになってしまいます。
具体的には、この変換はコード全体をitで囲っているので、このままだとメソッド定義があるとエラーになります。
そこで、itで囲むべき範囲を明示するためにもタグを使うことにしました。
タグはコードブロックには表示されないので、このような使い方もできるわけです。

def foo
  42
end

# tag::main[]
foo # => 42
# end::main[]

上のコードはこんな風に変換されます。

def foo
  42
end

require "spec"

it "code.cr" do
  (foo).inspect.should eq("42")
end

他にもいくつか機能がありますが、さすがに全部紹介するのは面倒なので省略します。
README.mdに詳細に書いてあるので、そちらを参考にしてください。

README.md

プロジェクトに対するアサーション

他にも、章の内容によっては実際にCrystalのプロジェクトを用意して、そちらのコードを示したいという要望があるであろうことが予想できました。
そこで、原稿があるディレクトリのprojectsディレクトリ以下にCrystalのプロジェクトを配置すると、依存関係のインストールを行ってから、そのプロジェクトのテストを実行するような処理も実装しました。

一応Makefileを書くと依存関係のインストール方法やテストの方法をカスタマイズできるのですが、これと言って拘ったところが無いので適当に割愛します。

RedPenによる自動校正

一応、RedPenに自動校正もコードの動作チェックに合わせてCIに組み込みました。

ただ、RedPenが役に立ったのかは微妙です。どちらかと言えば微妙な指摘に悩まされることの方が多かった気がします。
特にInvalidSymbolsが曲者で、こいつがインラインコードの記号まで指摘してきて、かなりのストレスでした。
(これは普通にバグです)

こんな設定でやっていたのですが、もし何かもっと上手い設定などありましたら教えてください。

config/redpen/conf.xml

<redpen-conf lang="ja">
  <validators>
    <!--Rules on sentence length-->
    <validator name="SentenceLength">
      <property name="max_len" value="100"/>
    </validator>
    <validator name="CommaNumber"/>
    <validator name="HeaderLength"/>

    <!--Rules on expressions-->
    <validator name="SuccessiveWord" />
    <validator name="JapaneseStyle" />
    <validator name="InvalidExpression" />
    <validator name="JapaneseExpressionVariation" level="Info"/>
    <validator name="DoubleNegative" />
    <validator name="Okurigana"/>
    <validator name="JapaneseNumberExpression"/>
    <validator name="JapaneseAmbiguousNounConjunction" />
    <validator name="JapaneseJoyoKanji" level="Warn"/>
    <validator name="LongKanjiChain" />
    <validator name="DoubledConjunctiveParticleGa" />
    <validator name="SuggestExpression">
      <property name="dict" value="config/redpen/suggestion.txt" />
    </validator>

    <!--Rules on symbols and terminologies-->
    <validator name="InvalidSymbol">
      <symbols>
        <symbol name="NUMBER_SIGN" value="#" invalid-chars="#" />
        <symbol name="COMMA" value="、" invalid-chars="," />
      </symbols>
    </validator>
    <validator name="KatakanaEndHyphen">
      <property name="list" value="ファイバー,コンパイルエラー" />
    </validator>
    <validator name="KatakanaSpellCheck" level="info"/>
    <validator name="SpaceBetweenAlphabeticalWord" />
    <validator name="ParenthesizedSentence">
      <property name="max_count" value="3"/>
      <property name="max_nesting_level" value="1"/>
      <property name="max_length" value="10"/>
    </validator>

    <!--Rules on sections and paragraphs-->
    <validator name="SectionLength">
      <property name="max_num" value="1500"/>
    </validator>
    <validator name="EmptySection" level="Info"/>
    <validator name="GappedSection" />
    <validator name="SectionLevel" />
    <validator name="ParagraphNumber">
      <property name="max_num" value="30" />
    </validator>
    <validator name="ListLevel" />

    <!--Load JavaScript validators-->
    <validator name="JavaScript" />
  </validators>
</redpen-conf>

また、その他にcrystal tool formatでCrystalのソースコードのフォーマットをチェックしたり、RubocopでRubyのコードをチェックしたりもしていました。

PDF・HTMLへの変換

印刷用、Web用のPDFはasciidoctor-pdfで、Web用のHTMLはJekyllにAsciidoctorを組み合わせて使いました。

JekyllでAsciidoctorを使うのは微妙にコツが入ります。が、Asciidoctorは思った以上に柔軟フォーマットなので案外どうにかなったりします。

_config.yml

Asciidoctorで日本語のPDFを作るのはそこまで大変ではないのですが、なぜかasciidoctor-pdfのページサイズでb5を指定しても正しくB5版になってくれなくて、[182mm, 257mm]のように縦横をmm単位で指定する必要があったので注意してください。

config/asciidoctor-pdf/themes/print-theme.yml
config/asciidoctor-pdf/themes/web-theme.yml

あとがき

何となく面白いことをやっているということが伝わったなら幸いです。

「Introducint Crystal Programming Language」はCrystalの解説書で、長く使えるものになることを目指しています。
その上で、こうした工夫をしてバージョンアップに適応できるようにすることは、とても大事なことだと考えています。
特に、今回期間を置いて印刷することになって、そのことを深く実感しました。

他にも、こうしたコードの実行チェックを行なうことで、原稿中のコードのタイポなども減るはずなので、これを取り入れることで単純に本のクオリティが高まるはずです。
そういう意味で、実装コストを除けば、実行チェックを行なわない理由はないはずです。
(個人的にはRedPenなどを入れるよりも価値があるのではないかな、と考えています。)

「Introducing Crystal Programming Language」は印刷できる段階までは行きましたが、内容的には若干欠けている部分があります。
そうした部分を埋めつつ、これからもCrystalのバージョンアップに合わせてメンテナンスしていく所存です。
また、もしこの記事を読むなりしてCrystalに興味を持った方がいて、欠けている内容を書いてみたいと思った方がいたら、issueやCrystal-JPのSlackで連絡してもらえば対応できると思います。
GitHubで公開しているので、誤字・脱字等を見つけたらissueやPRを送ってもらえると助かります。
気が向いたらWeb版のHTMLから直接issueを作るページへのリンクとか追加できたらいいのかな、とか考えていますが実行に移せていません。

最後に、繰り返しになりますが、Crystal-JPは明日(2018/10/8)に池袋で開催する技術書典5に「か62」のスペースで参加します。
もし良かったら、お手に取ってもらえると幸いです。
そうでなくとも、Web版で良いので読んでもらえたらな、と願っています。

https://techbookfest.org/event/tbf05/circle/25970003

こんな長い文章に最後まで目を通していただきありがとうございました。


追記 (2018/10/9)

技術書典当日、無事完売することができました。ありがとうございます。