✍️

Livebook livemdファイルのmix化によるモジュール共有

に公開

Livebookで作業していると「共通モジュールを定義して使いまわしたい」ことがあります。(standaloneなLivebookの話です)

ということで下記に一案を記載しました。
https://zenn.dev/ta_to/articles/35bc17d24efc98

ただ、がっつり複数モジュールを扱うコードをライブラリ化したいときは、mix化してしまうのもありかもしれません。

方針

  • 共通モジュール化したいノートで用意した「ボタン」を押すことで、同フォルダに同ノートのmixプロジェクトをつくる。
  • 使いたいノートではMix.install([{:my_lib, path: "~"}])をして使用する。

本格的なライブラリならそもそも独立して作る方が当然素直なため、これはあくまで簡易的な方法です。

mixを作るためのボタン(Kino.ExportToMix)実装

このKino自体は、先の記事で提示した共有方法でどこでも使えるようにしておきます。
(あるいはそもそもこのKino自体もmix化して共有する)

このKinoは、現在の.livemdファイルからMixプロジェクトを自動生成します。

  • defmoduleを含むセルからモジュールを抽出
  • Mix.installから依存関係を抽出
  • {filename}/mix.exs{filename}/lib/*.exを生成
setup
Mix.install([{:kino, "~> 0.14.0"}])
defmodule Kino.ExportToMix do
  use Kino.JS
  use Kino.JS.Live

  def new(env_file) do
    file_path = env_file |> URI.parse() |> Map.get(:path)
    livebook_node = Node.list(:connected) |> hd()
    Kino.JS.Live.new(__MODULE__, %{file_path: file_path, livebook_node: livebook_node})
  end

  @impl true
  def init(data, ctx), do: {:ok, assign(ctx, data: data)}

  @impl true
  def handle_connect(ctx), do: {:ok, %{}, ctx}

  @impl true
  def handle_event("export", _params, ctx) do
    data = ctx.assigns.data

    case export_to_mix(data.file_path, data.livebook_node) do
      {:ok, output_dir} ->
        relative_path = Path.relative_to_cwd(output_dir)
        broadcast_event(ctx, "result", %{
          success: true,
          message: "Successfully exported to: #{relative_path}"
        })

      {:error, reason} ->
        broadcast_event(ctx, "result", %{success: false, message: "Error: #{reason}"})
    end

    {:noreply, ctx}
  end

  defp export_to_mix(file_path, livebook_node) do
    with {:ok, content} <- File.read(file_path),
         {notebook, _} <- :rpc.call(livebook_node, Livebook.LiveMarkdown.Import, :notebook_from_livemd, [content]) do
      # プロジェクト名を決定
      base_name = Path.basename(file_path, ".livemd")
      output_dir = Path.join(Path.dirname(file_path), base_name)

      # 依存関係とモジュールを抽出
      deps_string = extract_deps_string(notebook)
      module_strings = extract_module_strings(notebook)

      # Mixプロジェクトを生成
      generate_mix_project(output_dir, base_name, deps_string, module_strings)

      {:ok, output_dir}
    else
      error -> {:error, inspect(error)}
    end
  end

  # setupセクションからMix.install([...])の依存関係リストを文字列として抽出
  # 戻り値: String.t() (例: "[{:kino, \"~> 0.14.0\"}]")
  defp extract_deps_string(notebook) do
    setup_cells =
      case notebook.setup_section do
        nil -> []
        section -> section.cells
      end

    result = extract_from_cells(setup_cells, &find_mix_install_in_source/1)
    List.first(result) || "[]"
  end

  # 全セクションからdefmoduleを文字列リストとして抽出
  # 戻り値: [String.t()] (例: ["defmodule Foo do ... end", "defmodule Bar do ... end"])
  defp extract_module_strings(notebook) do
    all_cells =
      Enum.flat_map(notebook.sections, fn section -> section.cells end)

    extract_from_cells(all_cells, &find_defmodules_in_source/1)
  end

  # セルリストから特定のパターンを抽出する汎用関数
  defp extract_from_cells(cells, extractor_fn) do
    cells
    |> Enum.filter(&elixir_code_cell?/1)
    |> Enum.flat_map(fn cell ->
      case Code.string_to_quoted(cell.source) do
        {:ok, ast} -> extractor_fn.(ast)
        _ -> []
      end
    end)
  end

  defp elixir_code_cell?(%{__struct__: Livebook.Notebook.Cell.Code, language: :elixir}), do: true
  defp elixir_code_cell?(_), do: false

  # ソースコードからMix.install([...])の依存関係リストを文字列として取得
  defp find_mix_install_in_source(ast) do
    find_in_ast(ast, fn
      {{:., _, [{:__aliases__, _, [:Mix]}, :install]}, _, [deps_list | _]} ->
        {:match, Macro.to_string(deps_list)}

      _ ->
        :no_match
    end)
  end

  # ソースコードからdefmoduleを文字列リストとして取得
  defp find_defmodules_in_source(ast) do
    find_in_ast(ast, fn
      {:defmodule, _, _} = node -> {:match, Macro.to_string(node)}
      _ -> :no_match
    end)
  end

  # ASTを再帰的に探索し、マッチャー関数でパターンを抽出
  # マッチャー関数は {:match, result} | :no_match を返す
  defp find_in_ast(ast, matcher_fn) do
    case ast do
      {:__block__, _, expressions} ->
        Enum.flat_map(expressions, &find_in_ast(&1, matcher_fn))

      node ->
        case matcher_fn.(node) do
          {:match, result} -> [result]
          :no_match -> []
        end
    end
  end

  defp generate_mix_project(output_dir, base_name, deps_string, module_strings) do
    # ディレクトリ作成
    lib_dir = Path.join(output_dir, "lib")
    File.mkdir_p!(lib_dir)

    # mix.exsを生成
    app_name_str = base_name |> String.replace("-", "_")
    app_name = String.to_atom(app_name_str)
    module_name = Macro.camelize(app_name_str)

    mix_exs_content = """
    defmodule #{module_name}.MixProject do
      use Mix.Project

      def project do
        [
          app: #{inspect(app_name)},
          version: "0.1.0",
          elixir: "~> 1.18",
          deps: deps()
        ]
      end

      defp deps do
        #{deps_string}
      end
    end
    """

    File.write!(Path.join(output_dir, "mix.exs"), mix_exs_content)

    # lib/配下にモジュールファイルを生成
    lib_file_path = Path.join(lib_dir, "#{base_name}.ex")

    lib_content = Enum.join(module_strings, "\n\n")
    File.write!(lib_file_path, lib_content)

    :ok
  end

  asset "main.js" do
    """
    export function init(ctx, data) {
      ctx.importCSS("main.css");

      ctx.root.innerHTML = `
        <div class="export-container">
          <button id="export-btn" class="export-btn">
            📦 Export to Mix Project
          </button>
          <div id="result" class="result-message"></div>
        </div>
      `;

      const btn = ctx.root.querySelector("#export-btn");
      const resultDiv = ctx.root.querySelector("#result");

      btn.addEventListener("click", () => {
        btn.disabled = true;
        btn.textContent = "⏳ Exporting...";
        ctx.pushEvent("export", {});
      });

      ctx.handleEvent("result", ({ success, message }) => {
        btn.disabled = false;
        btn.textContent = "📦 Export to Mix Project";

        resultDiv.style.display = "block";
        resultDiv.className = success ? "result-message success" : "result-message error";
        resultDiv.textContent = (success ? "✅ " : "❌ ") + message;
      });
    }
    """
  end

  asset "main.css" do
    """
    .export-container {
      padding: 16px;
      background: #f5f5f5;
      border-radius: 8px;
    }

    .export-btn {
      padding: 10px 20px;
      background: #2196F3;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
    }

    .result-message {
      margin-top: 12px;
      padding: 8px;
      display: none;
      border-radius: 4px;
    }

    .result-message.success {
      background: #c8e6c9;
      color: #2e7d32;
    }

    .result-message.error {
      background: #ffcdd2;
      color: #c62828;
    }
    """
  end
end
# assets生成のために実行が必須
Kino.ExportToMix.new(__ENV__.file)
# このライブラリ自体の共有(登録) LibStoreは https://zenn.dev/ta_to/articles/35bc17d24efc98 のもの
livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :register, [Node.self(), Kino.ExportToMix])

共有したいモジュールのノートサンプル

setup
Mix.install([
  {:kino, "~> 0.14.0"},
  {:statistics, "~> 0.6"}
])

共有したいモジュールサンプル

defmodule Helper do
  def hello do
    "hello from helper"
  end
end

defmodule LocalMixSample do
  defdelegate hello, to: Helper

  def mean(list) do
    Statistics.mean(list)
  end
end

mix化する先のKinoを用意して押下することで生成します。

livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :load, [Node.self(), Kino.ExportToMix])
Kino.ExportToMix.new(__ENV__.file)

そして生成後は、どのノートブックでも Mix.install({})でpathオプションで使用可能です。

Discussion