0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

84 言語の Hello World 博物館を作った — 汎用ミニ syntax highlighter とラウンドトリップテスト

0
Posted at

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 ラウンドトリップ)。

試してみる

おすすめの遊び方: パラダイム 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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?