Node.jsで文芸的プログラミング

この記事は Node.js 2 Advent Calender の 19日目の記事です。

みなさんは文芸的プログラミングというものをご存知でしょうか。 筆者はよく知らないのですが、プログラミングの神様ドナルド・クヌース先生が編み出したプログラミング技法で、 文芸的プログラミングで書かれたTeXのソースコードが出版されたりもしています。

いろいろなプログラマのエッセイなどを読むと、しばしば文芸的プログラミングに言及していたりするので、 いったいどんなものなのか以前から興味を持ってはいました。 そんな折、いつものように開発中のプログラムに必要な機能を満たしてくれるJavaScriptのライブラリをGoogleで検索していると、偶然、Node.jsで文芸的プログラミングをするためのプログラムにいき当たったのです。 いい機会なので、文芸的プログラミングというやつを、実際に体験してやろう。 そうして筆を取ったのが、この記事というわけです。

実は、はじめはNode.jsで作られた文芸的プログラミングの処理系を使ってみるという記事にする 予定だったのですが、調べてみると、文芸的プログラミングの処理系というのは、 各言語で星の数ほど作られていることが わかってきました。考えてもみると、マークダウンをベースとした実装であれば、簡単なものなら自分でも作れそうです。

そこで、この記事では、Node.jsで文芸的プログラミングの簡単な処理系を実装します。 おもちゃの実装なので、toylitという名前にします。

なお、この記事自体が、toylitで処理できるソースコードになっています。 ですから、以下のように、このブログ記事をコンパイルすることで、「このブログ記事をコンパイルできるプログラム」を生成することができます。

$ wget https://raw.githubusercontent.com/tai2/blog/master/content/Tech/literate-programming.md
$ toylit literate-programming.md -o output1.js # このブログ記事をコンパイル
$ npm install yargs marked # 依存モジュールのインストール
$ node output1.js literate-programming. -o output2.js # このブログ記事をコンパイルした結果のプログラムに、このブログ記事を与える
$ diff output1.js output2.js && echo 'results are same'
results are same

仕様

プログラム言語の理屈に合わせてコードを記述するのでなく、 人間の思考に合わせてプログラムを記述できるというのが、文芸的プログラミングのエッセンスです。 文芸的プログラミングでは、人間にとって重要な高レベルな抽象の記述に集中し、 低レベルな詳細の記述を後回しにできます。

クヌースの実装したCWEBという処理系では、 マクロと呼ばれる自然言語で表現された概念の下に、プログラムコードのチャンクを付随させます。 マクロの定義は、文中で後から追加していくことができます。 ソースコードからマクロを抽出・展開すると、プログラミング言語になります。

この記事では、ともかく最低限文芸的プログラミングと呼べなくもない何かができることを目標とするので、 高度な機能は実装しません。 文書をマークダウンとして記述し、見出しをマクロとした上で、見出しに付随するコードブロックを連結することで、 ソースコードを生成することにします。

以上がこのプログラムの仕様です。 記号は、マークダウンの記法と衝突しないものを適当に選びました。

&* 構成

プログラム全体は、以下のように構成されます。

& モジュールのインポート
& 文字列ストリームクラス
& Streamから文字列への変換
& 入力の切り替え
& パーサー
& コマンドライン引数
& メイン関数

コンパイルの流れ

Node.jsには、markedという優れたマークダウンパーサーがあるので、 マークダウンの処理にはこれを利用します。 markd自体は、内部的にASTを持っておらず、パースをすると一気にHTMLに変換されますが、 マークダウンという形式は、階層のないフラットな形式なので、トークン列が得られれば十分です。

& パーサー

function compile (text) {
  const tokens = marked.lexer(text)
  const chunks = collectCode(tokens)
  return concatCode(chunks)
}

compile関数は、マークダウンテキストを受け取って、JavaScriptソースコードを返します。 まず、markedでトークン列に変換します。

# Heading

Sample text.

```javascript
console.log('Hello World!')
```

このようなマークダウン文書をmarkedのlexerにかけると、

[ { type: 'heading', depth: 1, text: 'Heading' },
  { type: 'paragraph', text: 'Sample text.' },
  { type: 'code',
    lang: 'javascript',
    text: 'console.log(\'Hello World!\')' },
  links: {} ]

このような配列が得られます。 cllectCode関数でこのJSONからプログラムコードの断片を抽出し、concatCodeでそれらを結合します。

コード断片の抽出

それでは、コードの抽出部分から実装していきましょう。 仕様に書いたように、特殊な記法で書かれた見出しをマクロとみなして、 マクロとマクロの間にあるコードブロックをすべて集めます。

& パーサー

collectCodeは、markedのlexerが解析したトークン列を受け取る関数です。

function collectCode (tokens) {

まずは、収集したコード断片を格納するためのテーブルを用意します。

  const chunks = {
    root: [],
    codeTable: {}
  }

rootは、ルートマクロ専用で、codeTableは、見出しをキーに持つテーブルです。 ひとつのマクロに対応するコード断片が複数あっても良いので、要素は配列になります。

収集処理は、トークンを最初から順番に取り出しながら処理する状態機械で良さそうです。 状態は、3つ考えられます。開始直後の初期状態、ルートマクロを収集している状態、 その他のマクロを収集している状態です。

  const STATE_INIT = 0
  const STATE_IN_ROOT = 1
  const STATE_IN_MACRO = 2

これらの状態を格納する変数を用意しましょう。

  let state = STATE_INIT

また、現在収集中のマクロ名を保持しておくための変数も用意します。 chunks.codeTableのキーとして使います。

  let subject = ''

変数の準備ができたので、状態機械のループを実装します。

見出しトークンが来た場合は、isRootMacroisMacroを使って、それらがマクロかどうかを判定した上で、状態を変化させます。 非ルートマクロの場合は、初回の出現時のみcodeTableのエントリを空配列で初期化します。

  for (const token of tokens) {
    if (token.type === 'heading') {
      if (isRootMacro(token.text)) {
        state = STATE_IN_ROOT
      } else if (isMacro(token.text)) {
        state = STATE_IN_MACRO
        subject = extractSubject(token.text)
        if (!chunks.codeTable[subject]) {
          chunks.codeTable[subject] = []
        }
      }

コードブロックトークンの場合は、ルートマクロ走査中ならchunks.rootに、 非ルートマクロ処理中ならchunks.codeTableにコード断片を追加します。

また、この処理系で扱うのはJavaScriptのソースコードのみとします。

    } else if (token.type === 'code' && token.lang === 'javascript') {
      if (state === STATE_IN_ROOT) {
        chunks.root.push(token.text)
      } else if (state === STATE_IN_MACRO) {
        chunks.codeTable[subject].push(token.text)
      }
    }
  }

ループが終了すると、chunksには必要なコード断片が詰め込まれているので、返値として返します。

  return chunks
}

見出しがマクロかどうかの判定、およびマクロから記号を取り除いたテキストの抽出は、簡単な正規表現で行えます。

function isRootMacro (text) {
  return /^&\*.+$/.test(text)
}

function isMacro (text) {
  return /^&[^*]+$/.test(text)
}

function extractSubject (text) {
  return /^&([^*]+)$/.exec(text)[1].trim()
}

コード断片の結合

ここまででコード断片の抽出ができたので、次はそれらを結合します。

& パーサー

ルートマクロには特殊なチャンクが含まれます。 このチャンクは、改行区切りのフォーマットで、各行にはマクロが含まれています。 プログラムチャンクは、ルートマクロに書かれている順序で結合されます。

parseRootChunkでは、ルートマクロの内容を受け取って、 記号を取り除いた見出し文字列の配列を返します。

function parseRootChunk (text) {
  return text
    .split('\n')
    .filter(isMacro)
    .map(extractSubject)
}

concatCodeは、collectCodeで収集したchunkオブジェクトを受け取って、 ソースコード文字列を返します。 上で定義したparseRootChunkでまずはルートマクロを解析し、 そこで得られた順序に従って、マクロのコードチャンクを結合していきます。

function concatCode (chunks) {
  const subjects = parseRootChunk(chunks.root.join('\n'))

  return subjects.reduce((acc, subject) => {
    const code = chunks.codeTable[subject].join('\n')
    return acc + code + '\n'
  }, '')
}

CLIの実装

ここまでで本質的な処理は実装できました。 あとは、これを使ってCLIのプログラムを仕立てるだけです。

仕様の検討

このプログラムは、本質的にテキストからテキストへの変換なので、 典型的なUNIXのフィルタプログラムとして使えるようにしたいです。

そのため、最低限、標準入力からデータを受け取って、標準出力に書き出せる必要があります。

また、入力ファイル名が指定された場合は、標準入力ではなくファイルを入力とします。 入力ファイルは複数指定可能で、catのように指定された順番で結合して出力されます。

同様に、--outputで出力ファイル名が指定された場合は、ファイルを出力先とします。

おまけとして、--execが指定された場合は、コードを書き出すのではなく、その場で実行します。 この機能は、単に、筆者が「実行可能なマークダウン」というパワーワードを使いたかっただけです。

ブログ記事のためのおもちゃプログラムにしては過度な機能に聞こえるかもしれませんが、 Node.jsで、これらの異なる入出力を一般化して簡潔に書くことができるかやってみたかった だけなので、これで良いのです。

& メイン関数

プログラムのエントリーポイントを以下で定義していきます。

main関数は、async関数とします。

async function main () {

なぜかと言えば、Node.jsでは、標準入力とファイル入力を統一的に扱うには Stream APIを使う他なく、 Stream APIはコールバックベースのインターフェースであるため、 逐次的に読み易く書くには、Promiseでラップした上でasync/awaitを使うしかないからです。

出力先は、コマンドライン引数に応じて変化させます。詳細は後述。

  const argv = getArgv()

  const output = getOutput(argv)

そして、ファイル名が与えられたか否かに応じて入力を切り替えてコンパイルを実行します。

  if (argv._.length === 0) {
    await runCompile(process.stdin, output)
  } else {
    for (const filename of argv._) {
      const input = fs.createReadStream(filename)
      await runCompile(input, output)
    }
  }

標準入力とファイル入力を統一的に扱うことでrunCompileの分岐を無くす(後の枝のみに統一)ことも考えましたが、 その場合、単純にやると全入力ファイルを一気にオープンする形になってしまいます。 開くファイルは一度にひとつとしたかったため、分岐は許容することにしました。

もちろん、ファイルのオープンを遅延評価にすれば、要件を満たしつつ統一することも可能ですが、 そこまでがんばる気分にはなりませんでした。

最後に、execフラグが立っている場合は、抽出されたソースコードをevalで実行します。 execの場合のoutputの出力先は文字列になっています。

  if (argv.exec) {
    eval(output.toString()) // eslint-disable-line no-eval
  }
}

runCompile関数は、Readable StreamWritable Streamを引数に 取り、入力を変換した上で出力に流し込む関数です。

readInputPromiseを返すことによって、処理の流れが分断せずに読み易くなります。

async function runCompile (input, output) {
  const text = await readInput(input)
  const code = compile(text)
  output.write(code)
}

メイン関数が定義できたので、それを実行します。

main()

& Streamから文字列への変換

Streamのデータを蓄積して文字列に変換するreadInputを定義します。 やることは、受け取ったデータを配列に溜めておいて、Buffer.concatで結合するだけです。 Promiseで包むことによって、使いやすいAPIになります。

function readInput (stream) {
  return new Promise((resolve, reject) => {
    const chunks = []
    stream.on('data', chunk => chunks.push(chunk))
    stream.on('end', () => {
      resolve(Buffer.concat(chunks).toString('utf8'))
    })
    stream.on('error', err => {
      reject(err)
    })
  })
}

& 入力の切り替え

与えられたコマンドライン引数に応じて、出力先を切り替えます。 --execが与えられた場合は、後述するStringWritableによって、 いったんメモリ内にコード文字列を蓄積します。

function getOutput (argv) {
  if (argv.exec) {
    return new StringWritable()
  } else if (argv.output) {
    return fs.createWriteStream(argv.output)
  } else {
    return process.stdout
  }
}

& 文字列ストリームクラス

各入力ファイルに対応するプログラムコードを蓄積した上でevalにかけたいので、 そのための場所としてStringWritableクラスを定義します。 Writableを実装することで、ファイル出力や標準出力との統一的な扱いを実現しています。

class StringWritable extends Writable {
  constructor () {
    super()
    this.output = ''
  }
  _write (chunk, encoding, callback) {
    const str = encoding === 'buffer' ? chunk.toString('utf8') : chunk
    this.output += str
    callback()
  }
  toString () {
    return this.output
  }
}

& コマンドライン引数

コマンドライン引数は、yargsを使ってオブジェクトに変換しています。 使えるフラグは、--output--execの2つのみです。 フラグなしのパラメータは、入力ファイルとして扱われます。

function getArgv () {
  return require('yargs')
    .usage('Usage: $0 [-i filename] [-o filename]')
    .option('output', {
      alias: 'o',
      describe: 'output path. standard output when omitted.'
    })
    .option('exec', {
      alias: 'e',
      boolean: true,
      describe: 'execute program.'
    }).argv
}

& モジュールのインポート

このプログラムでは、以下のモジュールを使用しています。

const fs = require('fs')
const { Writable } = require('stream')
const marked = require('marked')

感想

コメント