Fortran (1957) から Mojo (2023) まで、84 言語の Hello World を年代別タイムラインで展示する「博物館」を作った。データ集めはもちろん楽しいのだが、実装で面白かったのは 2 点: (1) 84 言語のコードを色分けするのに highlight.js (数百 KB) を入れたくない → 汎用トークナイザ 1 つ + 宣言的プロファイル 27 種で自作、(2) トークナイザの正しさを「全 84 スニペットでトークン列を再結合すると元のコードに戻る」というラウンドトリップ性でテスト。言語の影響関係グラフ (
influences) を閉じた語彙にしてタイポをテストで弾く設計も含めて、「データが本体のアプリ」の作り方として書く。
🌐 デモ: https://sen.ltd/portfolio/hello-world-museum/
📦 GitHub: https://github.com/sen-ltd/hello-world-museum
データモデル: 言語 = 7 フィールド
{ name: "Rust", year: 2010,
paradigms: ["imperative", "functional", "concurrent"],
typing: "static",
family: "c", // syntax highlight プロファイル
influences: ["C++", "ML", "Haskell", "Erlang"], // 影響元 (閉じた語彙)
code: `fn main() {\n println!("Hello, World!");\n}` },
84 言語 × 7 フィールド。タイムライン・フィルタ・影響グラフ・色分けが全部この 1 テーブルから導出される。
ミニ syntax highlighter: 1 トークナイザ + 27 プロファイル
highlight.js も Prism も優秀だが、Hello World (1〜6 行) の色分けには過剰。コメント・文字列・キーワード・数値の 4 種が塗れれば十分で、それは汎用トークナイザ 1 つを言語ファミリごとのプロファイルでパラメタ化すれば書ける:
export const PROFILES = {
c: {
lineComment: "//",
blockComment: ["/*", "*/"],
strings: ['"', "'", "`"],
keywords: ["int", "void", "return", "fn", "func", ...],
},
python: { lineComment: "#", blockComment: null, strings: ['"', "'"], ... },
haskell: { lineComment: "--", blockComment: ["{-", "-}"], ... },
ml: { lineComment: null, blockComment: ["(*", "*)"], ... },
// 27 families
};
C ファミリ (C/C++/Java/JS/Go/Rust/Zig...) は 1 プロファイルを共有。Lisp 系 (Lisp/Scheme/Racket/Clojure) も同様。84 言語 → 27 プロファイルに圧縮できる。
トークナイザ本体は優先度付きの 1 パス:
export function tokenizeLine(line, profile) {
const tokens = [];
let i = 0;
while (i < line.length) {
// 1. 行コメント → 行末まで
if (profile.lineComment && line.startsWith(profile.lineComment, i)) {
push("com", line.slice(i));
break;
}
// 2. ブロックコメント (同一行内)
// 3. 文字列 (エスケープ \" 対応)
// 4. 数値 (識別子内の数字は除外)
// 5. 単語 → keywords 照合で kw / plain
// 6. その他 1 文字
}
return tokens;
}
優先度が重要: コメント > 文字列 > 数値 > 単語。# "not a string" はコメント全体であって文字列を含まない、"// not a comment" は文字列であってコメントではない。先にマッチした方が勝つ構造にすると自然にこの優先順位になる。
ラウンドトリップテスト: 一番効くやつ
トークナイザのテストで一番強いのは個別ケースではなく、「トークン列を連結すると必ず元のコードに戻る」という不変条件:
test("highlight() reassembles to the original code", () => {
for (const lang of LANGUAGES) {
const lines = highlight(lang.code, lang.family);
const rebuilt = lines.map((toks) => toks.map((t) => t.text).join("")).join("\n");
assert.equal(rebuilt, lang.code, `${lang.name}: lossy tokenization`);
}
});
これが効く理由:
- 文字の取りこぼし (インデックス進め忘れ) を全部検出する
- 文字の二重出力 (push 後に i を進めない) も検出する
- 84 言語の実データがそのままテストケースになるので、「Brainfuck の
+連打」「APL の'...'」「COBOL の行頭スペース」みたいなエッジケースを自分で考えなくていい
実際、書いてる途中に 2 回このテストに落ちた。エスケープ処理 (\\") で 1 文字スキップしすぎたケースと、数値の判定で直前の文字を見るときの line[i - 1] || "" の境界。どちらも個別テストでは書こうと思わないレベルの細部。
影響グラフ: 閉じた語彙でタイポを弾く
各言語の influences は手書きデータなので、「Smalltak」みたいなタイポが必ず入る。参照を閉じた語彙にする: データセット内の言語名 + 明示的な許可リスト (ISWIM、BCPL などデータセット外の歴史的言語) のどちらかに解決しなければテストで落とす:
export function unresolvedInfluences(pool = LANGUAGES, external = EXTERNAL_INFLUENCES) {
const names = new Set(pool.map((l) => l.name));
const ext = new Set(external);
const bad = new Set();
for (const lang of pool) {
for (const inf of lang.influences) {
if (!names.has(inf) && !ext.has(inf)) bad.add(`${lang.name} → ${inf}`);
}
}
return [...bad].sort();
}
test("all influence references resolve", () => {
assert.deepEqual(unresolvedInfluences(), []);
});
assert.deepEqual(..., []) にしているのは、落ちたときどの参照が壊れているかがエラーメッセージにそのまま出るから。assert.equal(bad.length, 0) だと「1 !== 0」しか分からない。
解決済みの参照だけでグラフを作ると、面白い派生クエリが書ける:
// データセット内の直接の子孫数ランキング
influenceRanking();
// → C: 13, Lisp: 13, ALGOL 60: 9, Haskell: 9, Java: 9, Smalltalk: 8 ...
// Lisp の子孫を BFS で辿る
descendants("Lisp");
// → Scheme, Common Lisp, Racket, Clojure, JavaScript (Scheme 経由), Ruby, ...
C と Lisp が同率 1 位、ALGOL 60 / Haskell / Java が並ぶ。Haskell が「使われてる言語」ではなく「影響を与えた言語」として上位に来るのがグラフの面白さ。
UI: 影響元をクリックで辿れる
各カードの影響元はデータセット内ならクリック可能。クリックすると検索ボックスにその言語名が入って絞り込まれる。Rust → C++ → C → ALGOL 60 と「言語の家系図」を遡れる。
card.querySelectorAll(".inf").forEach((el) => {
el.addEventListener("click", () => {
$("#q").value = el.dataset.lang;
render();
window.scrollTo({ top: 0, behavior: "smooth" });
});
});
設計
data.js ← 84 languages (データが本体)
core.js ← filtering, decade grouping, influence graph (DOM-free)
highlight.js ← generic tokenizer + 27 profiles (DOM-free)
app.js ← UI glue
テスト 32 個。データ整合性 (重複・年範囲・パラダイム語彙・プロファイル存在・影響解決) + ロジック (フィルタ AND・年代グルーピング・BFS) + トークナイザ (個別ケース + 全 84 ラウンドトリップ)。
試してみる
- デモ: https://sen.ltd/portfolio/hello-world-museum/
- GitHub: https://github.com/sen-ltd/hello-world-museum
おすすめの遊び方: パラダイム filter を「esoteric」にすると Brainfuck / Befunge / LOLCODE / INTERCAL / Whitespace / Piet / Shakespeare の 7 言語だけになる。Befunge の Hello World ("!dlroW ,olleH">:#,_@) は 2D 空間をプログラムカウンタが動き回る言語なので文字列が逆順。
まとめ
- Hello World の色分けに highlight.js は要らない。汎用トークナイザ + 宣言的プロファイルで 84 言語 → 27 プロファイル。
- トークナイザはラウンドトリップ性 (
join(tokens) === original) でテストする。実データ全件がエッジケース集になる。 - 優先度は コメント > 文字列 > 数値 > 単語。先にマッチした方が勝つ 1 パス構造で自然に実現できる。
- 手書きのグラフ参照 (
influences) は閉じた語彙 + 解決テストでタイポを機械的に弾く。 -
assert.deepEqual(bad, [])はassert.equal(bad.length, 0)より失敗時の情報量が多い。
これは SEN 合同会社の OSS ポートフォリオ #261 です。https://sen.ltd/portfolio/
