defmodule ActorSimulation.CAFGenerator do @moduledoc """ Generates C++ Actor Framework (CAF) code from ActorSimulation DSL. This module translates ActorSimulation definitions into production-ready CAF C++ projects with: - Typed actor implementations - Callback interfaces for custom behavior - CMake build configuration with Conan - CI pipeline for automated testing - Complete documentation ## Key Features - **Callback Modules**: Generate callback interfaces so users can add custom behavior WITHOUT touching generated code - **Type-Safe**: Uses CAF's typed actors for compile-time message checking - **Modern C++17**: Clean, idiomatic C++ code - **Production Ready**: Includes CI/CD pipeline and build system ## Example simulation = ActorSimulation.new() |> ActorSimulation.add_actor(:sender, send_pattern: {:periodic, 100, :msg}, targets: [:receiver]) |> ActorSimulation.add_actor(:receiver) {:ok, files} = CAFGenerator.generate(simulation, project_name: "MyActors", enable_callbacks: true) CAFGenerator.write_to_directory(files, "caf_output/") """ @doc """ Generates complete CAF project files from an ActorSimulation. ## Options - `:project_name` (required) - Name of the C++ project - `:enable_callbacks` (default: true) - Generate callback interfaces - `:caf_version` (default: "1.0.2") - CAF version for Conan ## Returns `{:ok, files}` where files is a list of `{filename, content}` tuples """ def generate(simulation, opts \\ []) do project_name = Keyword.fetch!(opts, :project_name) enable_callbacks = Keyword.get(opts, :enable_callbacks, true) caf_version = Keyword.get(opts, :caf_version, "1.0.2") actors = simulation.actors files = [] |> add_atoms_header(actors) |> add_actor_files(actors, enable_callbacks) |> add_main_file(actors, project_name) |> add_test_files(actors, project_name) |> add_cmake_file(actors, project_name) |> add_conan_file(caf_version) |> add_ci_pipeline() |> add_readme(project_name) {:ok, files} end @doc """ Writes generated files to a directory. Creates the directory and all subdirectories as needed. """ def write_to_directory(files, output_dir) do ActorSimulation.GeneratorUtils.write_to_directory(files, output_dir) end # Private functions defp add_atoms_header(files, actors) do content = generate_atoms_header(actors) [{"atoms.hpp", content} | files] end defp add_actor_files(files, actors, enable_callbacks) do Enum.reduce(actors, files, fn {name, actor_info}, acc -> case actor_info.type do :simulated -> definition = actor_info.definition actor_name = actor_snake_case(name) header = generate_actor_header(name, definition, enable_callbacks) source = generate_actor_source(name, definition, enable_callbacks) new_files = [ {"#{actor_name}_actor.cpp", source}, {"#{actor_name}_actor.hpp", header} ] new_files = if enable_callbacks do callback_impl = generate_callback_impl(name, definition) new_files ++ [{"#{actor_name}_callbacks_impl.cpp", callback_impl}] else new_files end new_files ++ acc :real_process -> # Skip real processes for CAF generation acc end end) end defp add_main_file(files, actors, project_name) do content = generate_main(actors, project_name) [{"main.cpp", content} | files] end defp add_test_files(files, actors, project_name) do test_content = generate_test_file(actors, project_name) [{"test_actors.cpp", test_content} | files] end defp add_cmake_file(files, actors, project_name) do content = generate_cmake(actors, project_name) [{"CMakeLists.txt", content} | files] end defp add_conan_file(files, caf_version) do content = generate_conan(caf_version) [{"conanfile.txt", content} | files] end defp add_ci_pipeline(files) do content = generate_ci_pipeline() [{".github/workflows/ci.yml", content} | files] end defp add_readme(files, project_name) do content = generate_readme(project_name) [{"README.md", content} | files] end defp generate_actor_header(name, definition, enable_callbacks) do actor_name = actor_snake_case(name) class_name = "#{actor_name}_actor" callback_interface = if enable_callbacks do generate_callback_interface_definition(name, definition) else "" end callback_member = if enable_callbacks do """ std::shared_ptr<#{actor_name}_callbacks> callbacks_; """ else "" end target_names = Enum.map(definition.targets, &actor_snake_case/1) target_members = if length(target_names) > 0 do """ std::vector targets_; int send_count_ = 0; """ else "" end """ // Generated from ActorSimulation DSL // Actor: #{name} // DO NOT EDIT - This file is auto-generated #pragma once #include #include #include #include "atoms.hpp" #{callback_interface} class #{class_name} : public caf::event_based_actor { public: #{class_name}(caf::actor_config& cfg, const std::vector& targets); caf::behavior make_behavior() override; private: void schedule_next_send(); void send_to_targets(); #{callback_member}#{target_members} }; """ end defp generate_actor_source(name, definition, enable_callbacks) do actor_name = actor_snake_case(name) class_name = "#{actor_name}_actor" callback_init = if enable_callbacks do """ callbacks_ = std::make_shared<#{actor_name}_callbacks>(); """ else "" end # Only initialize targets_ if actor has targets has_targets = length(definition.targets) > 0 targets_init = if has_targets, do: ", targets_(targets)", else: "" # Suppress warning for unused targets parameter when not needed unused_targets_suppress = if has_targets, do: "", else: " (void)targets; // Unused but required for API consistency\n" behavior_handlers = generate_behavior_handlers(name, definition, enable_callbacks) schedule_impl = generate_schedule_impl(definition) send_impl = generate_send_impl(definition) """ // Generated from ActorSimulation DSL // Actor: #{name} #include "#{actor_name}_actor.hpp" #include #{class_name}::#{class_name}(caf::actor_config& cfg, const std::vector& targets) : caf::event_based_actor(cfg)#{targets_init} { #{unused_targets_suppress}#{callback_init}} caf::behavior #{class_name}::make_behavior() { schedule_next_send(); return { #{behavior_handlers} }; } void #{class_name}::schedule_next_send() { #{schedule_impl}} void #{class_name}::send_to_targets() { #{send_impl}} """ end defp generate_behavior_handlers(_name, definition, enable_callbacks) do messages = extract_messages_from_pattern(definition.send_pattern) handlers = Enum.map(messages, fn msg -> msg_name = message_name(msg) callback_call = if enable_callbacks do """ callbacks_->on_#{msg_name}(); """ else """ // Message received: #{msg} """ end # CAF 1.0: Use atom constant in handler signature atom_name = get_atom_name_from_message(msg) """ [=](#{atom_name}_atom) { #{callback_call} send_to_targets(); schedule_next_send(); } """ end) if length(handlers) > 0 do Enum.join(handlers, ",\n") else """ [=](event_atom) { // Default message handler send_to_targets(); schedule_next_send(); } """ end end defp generate_schedule_impl(definition) do case definition.send_pattern do nil -> """ // No automatic sending pattern """ {:periodic, interval_ms, message} -> msg_atom = message_to_atom(message) """ // CAF 1.0: Use mail API instead of deprecated delayed_send mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this); """ {:rate, per_second, message} -> interval_ms = div(1000, per_second) msg_atom = message_to_atom(message) """ // CAF 1.0: Use mail API instead of deprecated delayed_send mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this); """ {:burst, count, interval_ms, message} -> msg_atom = message_to_atom(message) """ // CAF 1.0: Use mail API instead of deprecated delayed_send for (int i = 0; i < #{count}; i++) { mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this); } """ {:self_message, delay_ms, message} -> msg_atom = message_to_atom(message) """ // CAF 1.0: Send message to self after delay (one-shot) using mail API mail(#{msg_atom}).delay(std::chrono::milliseconds(#{delay_ms})).send(this); """ end end defp generate_send_impl(definition) do if length(definition.targets) > 0 do """ for (auto& target : targets_) { // CAF 1.0: Use mail API instead of send mail(msg_atom_v).send(target); send_count_++; } """ else """ // No targets to send to """ end end defp generate_callback_interface_definition(name, definition) do actor_name = actor_snake_case(name) class_name = "#{actor_name}_callbacks" messages = extract_messages_from_pattern(definition.send_pattern) methods = Enum.map(messages, fn msg -> msg_name = message_name(msg) " virtual void on_#{msg_name}();" end) methods_str = if length(methods) > 0 do Enum.join(methods, "\n") else " virtual void on_message();" end """ // Callback interface for: #{name} // This is the contract - do not modify class #{class_name} { public: virtual ~#{class_name}() = default; #{methods_str} }; """ end defp generate_callback_impl(name, definition) do actor_name = actor_snake_case(name) class_name = "#{actor_name}_callbacks" messages = extract_messages_from_pattern(definition.send_pattern) methods = Enum.map(messages, fn msg -> msg_name = message_name(msg) # If actor has send_pattern, it's the sender (publisher) action = if definition.send_pattern, do: "Sending", else: "Received" """ void #{class_name}::on_#{msg_name}() { // TODO: Implement custom behavior for #{msg} // This is called when the actor #{if action == "Sending", do: "sends", else: "receives"} a #{msg} message std::cout << "#{name}: #{action} #{msg} message" << std::endl; } """ end) methods_str = if length(methods) > 0 do Enum.join(methods, "\n") else """ void #{class_name}::on_message() { // TODO: Implement custom behavior std::cout << "#{name}: Processing message" << std::endl; } """ end """ // Generated from ActorSimulation DSL // Callback implementation for: #{name} // // CUSTOMIZE THIS FILE - This is where you add your custom behavior! // The interface is defined in #{actor_name}_actor.hpp #include "#{actor_name}_actor.hpp" #include #{methods_str} """ end defp generate_main(actors, project_name) do simulated_actors = actors |> Enum.filter(fn {_name, info} -> info.type == :simulated end) |> Enum.map(fn {name, info} -> {name, info.definition} end) includes = Enum.map(simulated_actors, fn {name, _def} -> "#include \"#{actor_snake_case(name)}_actor.hpp\"" end) spawn_code = generate_spawn_code(simulated_actors) """ // Generated from ActorSimulation DSL // Main entry point for #{project_name} #include #include #include #{Enum.join(includes, "\n")} using namespace caf; int caf_main(actor_system& system) { // Spawn all actors #{spawn_code} // Keep system alive - wait for user input to exit std::cout << "Actor system started. Press Ctrl+C to exit." << std::endl; std::cout << "Press Enter to stop..." << std::endl; // Keep the system running std::string line; std::getline(std::cin, line); return 0; } CAF_MAIN() """ end defp generate_spawn_code(actors) do # First pass: spawn actors without targets (collect them first) spawn_statements_pass1 = Enum.map(actors, fn {name, _def} -> actor_name = actor_snake_case(name) " auto #{actor_name} = system.spawn<#{actor_name}_actor>(std::vector{});" end) # Second pass: re-spawn actors with proper targets spawn_statements_pass2 = Enum.map(actors, fn {name, def} -> actor_name = actor_snake_case(name) if length(def.targets) > 0 do target_refs = Enum.map_join(def.targets, ", ", &actor_snake_case/1) " // Re-spawn #{actor_name} with proper targets\n #{actor_name} = system.spawn<#{actor_name}_actor>(std::vector{#{target_refs}});" else "" end end) |> Enum.filter(&(&1 != "")) all_spawns = spawn_statements_pass1 ++ spawn_statements_pass2 Enum.join(all_spawns, "\n") end defp generate_test_file(actors, project_name) do simulated_actors = actors |> Enum.filter(fn {_name, info} -> info.type == :simulated end) |> Enum.map(fn {name, info} -> {name, info.definition} end) includes = Enum.map(simulated_actors, fn {name, _def} -> "#include \"#{actor_snake_case(name)}_actor.hpp\"" end) """ // Generated from ActorSimulation DSL // Catch2 tests for #{project_name} #include #include #include "atoms.hpp" #{Enum.join(includes, "\n")} using namespace caf; // Simple compilation tests - verifying that generated code compiles // Note: CAF 1.0 requires init_global_meta_objects<>() before creating actor_system // This is handled by CAF_MAIN() in the main application TEST_CASE("Headers compile successfully", "[compilation]") { // Just verify that all headers can be included without errors SUCCEED("All headers compiled successfully"); } TEST_CASE("Atom definitions are valid", "[atoms]") { // Verify atoms are accessible (event_atom exists in all generated examples) [[maybe_unused]] auto test_atom = event_atom_v; SUCCEED("Atoms defined successfully"); } """ end defp generate_cmake(actors, project_name) do simulated_actors = actors |> Enum.filter(fn {_name, info} -> info.type == :simulated end) |> Enum.map(fn {name, _info} -> name end) sources = simulated_actors |> Enum.flat_map(fn name -> actor_name = actor_snake_case(name) [" #{actor_name}_actor.cpp", " #{actor_name}_callbacks_impl.cpp"] end) sources_str = Enum.join([" main.cpp" | sources], "\n") """ cmake_minimum_required(VERSION 3.15) project(#{project_name} CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Detect OS for binary naming: {example}.caf.{os} if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") set(OS_SUFFIX "darwin") elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") set(OS_SUFFIX "linux") elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(OS_SUFFIX "exe") else() set(OS_SUFFIX "bin") endif() # Find CAF find_package(CAF REQUIRED COMPONENTS core io) # Find Catch2 for testing find_package(Catch2 3 REQUIRED) # Source files set(SOURCES #{sources_str} ) # Create executable add_executable(#{project_name} ${SOURCES}) # Set output binary name: {example}.caf.{os} set_target_properties(#{project_name} PROPERTIES OUTPUT_NAME "#{project_name}.caf.${OS_SUFFIX}" ) # Link CAF libraries target_link_libraries(#{project_name} PRIVATE CAF::core CAF::io ) # Enable warnings if(MSVC) target_compile_options(#{project_name} PRIVATE /W4) else() target_compile_options(#{project_name} PRIVATE -Wall -Wextra -pedantic) endif() # Test executable (needs actor sources) set(TEST_SOURCES test_actors.cpp #{Enum.join(sources |> Enum.filter(&(&1 != " main.cpp")), "\n")} ) add_executable(#{project_name}_test ${TEST_SOURCES}) target_link_libraries(#{project_name}_test PRIVATE CAF::core CAF::io Catch2::Catch2WithMain ) # Enable testing enable_testing() add_test(NAME #{project_name}_test COMMAND #{project_name}_test) # Generate JUnit XML report for CI add_test( NAME #{project_name}_test_junit COMMAND #{project_name}_test --reporter junit --out test-results.xml ) """ end defp generate_conan(caf_version) do """ [requires] caf/#{caf_version} catch2/3.7.1 [generators] CMakeDeps CMakeToolchain [options] caf/*:shared=False """ end defp generate_ci_pipeline do """ name: CI on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest] build_type: [Debug, Release] steps: - uses: actions/checkout@v3 - name: Cache Conan packages uses: actions/cache@v3 with: path: ~/.conan2 key: ${{ runner.os }}-conan-${{ matrix.build_type }}-${{ hashFiles('**/conanfile.txt') }} restore-keys: | ${{ runner.os }}-conan-${{ matrix.build_type }}- ${{ runner.os }}-conan- - name: Install Conan run: | pip install conan conan profile detect --force - name: Install dependencies run: | mkdir -p build cd build conan install .. --build=missing -s build_type=${{ matrix.build_type }} - name: Configure run: | cd build cmake .. -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake - name: Build run: | cd build cmake --build . --config ${{ matrix.build_type }} - name: Run Demo run: | cd build # Determine binary name: {project}.caf.{os} OS_NAME=$(uname -s | tr '[:upper:]' '[:lower:]') PROJECT_NAME=$(grep -o 'project([^)]*)' ../CMakeLists.txt | head -1 | sed 's/project(\\([^ ]*\\).*/\\1/') BINARY="${PROJECT_NAME}.caf.${OS_NAME}" # Run demo for 3 seconds timeout 3s ./"${BINARY}" || true - name: Test run: | cd build ctest -C ${{ matrix.build_type }} --output-on-failure - name: Run Catch2 tests with verbose output run: | cd build ./*_test --success - name: Generate JUnit test report if: always() run: | cd build ./*_test --reporter junit --out test-results.xml || true - name: Publish Test Results if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: files: | build/test-results.xml check_name: "Test Results (${{ matrix.os }}, ${{ matrix.build_type }})" """ end defp generate_readme(project_name) do """ # #{project_name} Generated from ActorSimulation DSL using CAF (C++ Actor Framework). ## About This project uses the [C++ Actor Framework (CAF)](https://actor-framework.org/) to implement a distributed actor system. The code is generated from a high-level Elixir DSL and provides: - Type-safe actor implementations - Callback interfaces for custom behavior - Modern C++17 code - Production-ready build system ## Prerequisites - CMake 3.15+ - C++17 compiler (GCC 7+, Clang 5+, MSVC 2019+) - Conan package manager - CAF library (installed via Conan) ## Building ```bash # Install dependencies mkdir build && cd build conan install .. --build=missing # Configure and build cmake .. -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake cmake --build . # Run ./#{project_name} ``` ## Testing ```bash # Run tests with CTest cd build ctest --output-on-failure # Run Catch2 tests with verbose output ./#{project_name}_test --success # Generate JUnit XML report ./#{project_name}_test --reporter junit --out test-results.xml ``` The CI pipeline automatically generates and publishes JUnit test reports. ## Customizing Behavior The generated actor code uses callback interfaces to allow customization WITHOUT modifying generated files: 1. Find the `*_callbacks_impl.cpp` files 2. Implement your custom logic in the callback methods 3. Rebuild the project The generated actor code will automatically call your callbacks. ## Project Structure - `main.cpp` - Entry point and actor system setup - `*_actor.hpp/cpp` - Generated actor implementations (DO NOT EDIT) - `*_callbacks.hpp` - Callback interface definitions (DO NOT EDIT) - `*_callbacks_impl.cpp` - Callback implementations (EDIT THIS!) - `CMakeLists.txt` - Build configuration - `conanfile.txt` - Package dependencies ## CI/CD This project includes a GitHub Actions workflow that: - Builds on Ubuntu and macOS - Tests Debug and Release configurations - Validates the build with each commit ## Learn More - [CAF Documentation](https://actor-framework.readthedocs.io/) - [CAF GitHub](https://github.com/actor-framework/actor-framework) - [ActorSimulation DSL](https://github.com/yourusername/gen_server_virtual_time) ## License Generated code is provided as-is for your use. """ end # Utility functions defp actor_snake_case(atom) when is_atom(atom) do atom |> Atom.to_string() |> String.replace("-", "_") end defp extract_messages_from_pattern(nil), do: [] defp extract_messages_from_pattern({:periodic, _interval, message}), do: [message] defp extract_messages_from_pattern({:rate, _per_second, message}), do: [message] defp extract_messages_from_pattern({:burst, _count, _interval, message}) do [message] end defp extract_messages_from_pattern({:self_message, _delay, message}) do [message] end defp message_name(msg) when is_atom(msg) do Atom.to_string(msg) end defp message_name(msg) when is_binary(msg), do: msg defp message_name(msg) do inspect(msg) |> String.replace(~r/[^a-z0-9_]/, "_") end # CAF 1.0: Generate shared atoms header that all actors include defp generate_atoms_header(actors) do # Collect all unique atoms from all actors all_atoms = actors |> Enum.filter(fn {_name, info} -> info.type == :simulated end) |> Enum.flat_map(fn {_name, info} -> collect_atoms_from_definition(info.definition) end) |> Enum.uniq() |> Enum.sort() atom_defs = Enum.map(all_atoms, fn atom_str -> " CAF_ADD_ATOM(ActorSimulation, #{atom_str}_atom)" end) """ // Generated from ActorSimulation DSL // Shared atom definitions for all actors #pragma once #include // CAF 1.0: Define custom type ID block for atoms CAF_BEGIN_TYPE_ID_BLOCK(ActorSimulation, caf::first_custom_type_id) #{Enum.join(atom_defs, "\n")} CAF_END_TYPE_ID_BLOCK(ActorSimulation) """ end defp collect_atoms_from_definition(definition) do # Always include "event" and "msg" atoms base_atoms = ["event", "msg"] # Extract atom from send_pattern if present pattern_atom = extract_atom_from_send_pattern(definition.send_pattern) # Combine all atoms and convert to list atoms = if pattern_atom, do: [pattern_atom | base_atoms], else: base_atoms Enum.uniq(atoms) end defp extract_atom_from_send_pattern({:periodic, _interval, msg}) when is_atom(msg), do: Atom.to_string(msg) defp extract_atom_from_send_pattern({:rate, _rate, msg}) when is_atom(msg), do: Atom.to_string(msg) defp extract_atom_from_send_pattern({:burst, _count, _interval, msg}) when is_atom(msg), do: Atom.to_string(msg) defp extract_atom_from_send_pattern({:self_message, _delay, msg}) when is_atom(msg), do: Atom.to_string(msg) defp extract_atom_from_send_pattern(_), do: nil defp get_atom_name_from_message(msg) when is_atom(msg) do Atom.to_string(msg) end defp get_atom_name_from_message(msg) when is_binary(msg) do msg end defp get_atom_name_from_message(_msg) do "msg" end defp message_to_atom(msg) when is_atom(msg) do "#{msg}_atom_v" end defp message_to_atom(msg) when is_binary(msg) do "#{msg}_atom_v" end defp message_to_atom(_msg) do "msg_atom_v" end end