ドキュメント、テスト、コードは、同じことをまともに語れない3つのファイルだ。

ソースの関数シグネチャを更新する。READMEのサンプルを忘れる。1週間後、新入社員が古くなったスニペットを本番にコピーする。テストファイルはまだ古い動作を期待結果としてエンコードしている。これでバグが2つ、ドキュメントチケットが1つだ。

これが同期問題だ。どのコードベースにもある。ほとんどのチームは規律で解決しようとするが、誰かが焦った時点で失敗する。

Literate programmingは、散文ファイルをsingle source of truthにすることでこれを修正する。人間が読むMarkdownドキュメントを書く。小さなツールがコードブロックを実行可能なPythonファイルに抽出する。テスト、実装、説明がすべて同じ場所に存在する。散文を変更すれば、コードも変更される。

Literate programmingとは?

Donald Knuthが1984年に造語した。アイデアはシンプルだ。エッセイを書くようにプログラムを書く。自然言語でロジックを説明し、コードを挟み込み、ツールtangleが実行可能なソースを抽出し、weaveが整形されたドキュメントを生成する。

KnuthのWEBシステムのような古典的ツールは、PascalとTeXに密接に結びついていた。主流のソフトウェア開発では普及しなかった。ワークフローが異質に感じられ、ツールが重く、多くのプログラマはPDFではなくIDEでコードを読みたかった。

しかし、核心的な洞察は今も有効だ。散文とコードはsingle source of truthを共有すべきだ。現代の言語とMarkdownは、Knuthが想像した以上にこれを容易にする。特別なコンパイラは不要だ。Python30行とMarkdownパーサーで十分だ。

1つのMarkdownファイルがテストスイートになる仕組み

メカニズムは単純だ。.mdファイルにfenced code blocksを書く。各ブロック内のヘッダーコメントが、抽出ツールにそのブロックがどのファイルに属するかを伝える。抽出ツールは同じヘッダーを持つブロックを1つのPythonモジュールに連結する。そして結果に対してpytestを実行する。

完全な例を示す。calculator.mdとして保存する:

# Calculator: addition and its properties

The `add` function is trivial. That is the point. Even trivial code deserves context about why it exists and what invariants it preserves.

```python
# calc.py
def add(a: int, b: int) -> int:
    """Return the sum of two integers."""
    return a + b

Addition commutes. We verify this explicitly because it is a property future maintainers might break by accident if they switch to a different implementation.

# test_calc.py
from calc import add

def test_addition_commutes():
    assert add(2, 3) == add(3, 2)

def test_identity():
    assert add(7, 0) == 7

`# calc.py`と`# test_calc.py`のコメントは魔法の構文ではない。私たちのスクリプトが理解する規約だ。

## 抽出スクリプト

これがツール全体だ。`tangle.py`として保存する:

```python
import re
import tempfile
import subprocess
from pathlib import Path


def tangle(md_path: Path, out_dir: Path) -> Path:
    """Extract code blocks from Markdown into runnable Python files."""
    content = md_path.read_text()
    files: dict[str, list[str]] = {}

    # Extract all python code blocks
    for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
        lines = block.strip().split("\n")

        # First line like '# filename.py' sets the target file
        if lines and lines[0].startswith("# "):
            filename = lines[0][2:].strip()
            code = lines[1:]
        else:
            filename = "module.py"
            code = lines

        files.setdefault(filename, []).extend(code)

    # Write extracted files
    out_dir.mkdir(parents=True, exist_ok=True)
    for filename, code_lines in files.items():
        (out_dir / filename).write_text("\n".join(code_lines) + "\n")

    return out_dir


if __name__ == "__main__":
    out = tangle(Path("calculator.md"), Path("build"))
    result = subprocess.run(
        ["python", "-m", "pytest", str(out), "-v"],
        capture_output=False,
    )
    raise SystemExit(result.returncode)

実行する:

$ python tangle.py
============================= test session starts ==============================
build/test_calc.py::test_addition_commutes PASSED
build/test_calc.py::test_identity PASSED
============================== 2 passed in 0.01s

build/ディレクトリにはcalc.pytest_calc.pyが含まれるようになった。インポートしたり、型チェックしたり、パッケージとして配布したりできる。Markdownファイルが正規のソースだ。それ以外はすべて生成される。

1つのファイルが助ける時と、害する時

このアプローチは、ライブラリ、アルゴリズム、そしてなぜと同じくらい重要なもの全てで輝く。APIドキュメント、研究用コード、設定パイプラインはすべて、実装に結びついた散文から恩恵を受ける。

ボイラープレートが多いアプリケーションコードには輝かない。15個のデコレーターインポートを持つDjangoビューにエッセイは不要だ。モジュールが主にフレームワークの配管なら、literate構造のオーバーヘッドは明確さなしに摩擦を加えるだけだ。

もう1つの制限はツールサポートだ。IDEは.pyファイルにコードがあることを期待する。ジャンプトゥディフィニション、インラインリンティング、オートコンプリートはすべて、生成されたファイルが事前に存在する必要がある。これはtangle.pyをpre-commitフックやビルドステップの一部として実行することで解決できる。しかし、それは追加のステップだ。チームがすでにビルドの複雑さに苦労しているなら、カスタム抽出パイプラインを追加する価値はないかもしれない。

これを実現する実際のツール

上記の30行スクリプトで始めるのに十分だ。プロダクション対応のものが欲しいなら、成熟したオプションがある。

Entangledは、どの言語でも動作するモダンなliterate programmingツールだ。構文は少し異なるが、アイデアは同じだ。コードブロック間の依存関係を追跡し、複数の出力ファイルをサポートする。

Jupyter notebooksは、データサイエンスにおいて同様の問題を解決する。1つのファイルに散文、コード、出力を混在させる。欠点は、ノートブックがバージョン管理に不向きなことだ。差分は読めないし、マージコンフリクトはよく起きる。

Org-mode with Babelが最も強力な実装だ。すでにEmacsを使っているなら、比類がない。そうでなければ、学習曲線は急だ。

ほとんどのチームにとって、シンプルなMarkdown抽出ツールが実用的な中道だ。誰もがすでに理解しているツールを使う。

FAQ

型チェッカーでも動作するか?

はい。まず.pyファイルを生成し、ビルドディレクトリに対してmypypyrightを実行する。

Python以外の言語はどうか?

tangle.pyスクリプトは言語に依存しない。正規表現をpythonからrustgoに変更すれば、同じように動作する。1つのドキュメントで言語を混在させることもできる。

ブロック間のインポートはどう扱うか?

抽出ツールは、Markdown内に表示された順序で、同じファイル名を持つすべてのブロックを連結する。そのファイルの最初のブロックにインポートを保持するか、繰り返す。Pythonは重複したインポートを優雅に処理する。

1つのモジュールから始める

コードベース全体を書き換える必要はない。ドキュメントが常にコードの後れを取っているように見える小さなモジュールを1つ選ぶ。Markdownファイルに変換し、抽出スクリプトをCIパイプラインに追加し、生成された出力に対してテストを実行する。

ドキュメントがリマインダーなしで最新のままなら、アプローチが機能していると分かる。オーバーヘッドのように感じるなら、放棄すればいい。Literate programmingはツールであり、宗教ではない。目標はKnuthを感動させることではない。3つの異なるファイルで自分に嘘をつくのをやめることだ。