defmodule Mix.Tasks.Compile.ModuleIndexer do @moduledoc """ Custom Mix compiler that generates NFTables.ExprIndex module. This compiler runs after the standard Elixir compiler and: 1. Queries all compiled modules in the :nftables application 2. Filters for modules matching NFTables.Expr.* prefix 3. Generates and compiles NFTables.ExprIndex with all() function The generated module is used by the `use NFTables` macro to dynamically import all expression building modules. """ use Mix.Task.Compiler @recursive true @manifest ".compile.module_indexer" @doc """ Runs the module indexer compiler. Returns: - `{:ok, []}` on success with changes - `{:noop, []}` when no changes detected """ def run(_args) do # Discover all NFTables.Expr.* modules expr_modules = discover_expr_modules() # Check if module list changed (compare hash) if modules_changed?(expr_modules) do # Generate and compile ExprIndex module generate_expr_index(expr_modules) # Write manifest with hash write_manifest(expr_modules) Mix.shell().info("Generated NFTables.ExprIndex with #{length(expr_modules)} modules") {:ok, []} else # No changes, skip regeneration {:noop, []} end end @doc """ Cleans up generated files. """ def clean do File.rm(@manifest) File.rm("lib/nftables_expr_index.ex") :ok end # Private functions defp discover_expr_modules do # Read modules from the .app file generated by elixir compiler app_file = Mix.Project.app_path() <> "/ebin/#{Mix.Project.config()[:app]}.app" if File.exists?(app_file) do # Read and parse the .app file {:ok, [{:application, _app, properties}]} = :file.consult(String.to_charlist(app_file)) # Extract modules list modules = Keyword.get(properties, :modules, []) # Filter for NFTables.Expr.* modules modules |> Enum.filter(&expr_module?/1) |> Enum.sort() else # .app file doesn't exist yet (very first compile) [] end end defp expr_module?(module) do module_string = to_string(module) # Match NFTables.Expr.* but exclude NFTables.Expr itself and internal modules String.starts_with?(module_string, "Elixir.NFTables.Expr.") and module_string != "Elixir.NFTables.Expr" and not String.ends_with?(module_string, ".Structs") end defp modules_changed?(current_modules) do current_hash = compute_hash(current_modules) case read_manifest() do {:ok, previous_hash} -> current_hash != previous_hash :error -> # No manifest exists, first run true end end defp compute_hash(modules) do :erlang.phash2(modules) end defp generate_expr_index(modules) do # Generate module code as a string module_code = """ defmodule NFTables.ExprIndex do @moduledoc \"\"\" Auto-generated module index for NFTables.Expr modules. This module is generated at compile time by Mix.Tasks.Compile.ModuleIndexer. DO NOT EDIT - regenerated on each compilation when module list changes. Contains #{length(modules)} modules. \"\"\" @doc \"\"\" Returns list of all NFTables.Expr submodules. \"\"\" def all do #{inspect(modules)} end end """ # Write to file output_path = "lib/nftables_expr_index.ex" File.write!(output_path, module_code) :ok end defp write_manifest(modules) do hash = compute_hash(modules) content = :erlang.term_to_binary(hash) File.write!(@manifest, content) end defp read_manifest do if File.exists?(@manifest) do content = File.read!(@manifest) hash = :erlang.binary_to_term(content) {:ok, hash} else :error end end end