//// Protozoa Dev - Protocol Buffer Compiler CLI for Gleam //// //// CLI tool for generating Gleam code from Protocol Buffer (.proto) files with complete proto3 support. import argv import filepath import gleam/int import gleam/io import gleam/list import gleam/result import gleam/string import protozoa/internal/codegen import protozoa/internal/import_resolver import protozoa/internal/project import protozoa/parser import shellout import simplifile import snag.{type Result} pub type Command { Generate Check } pub type ChangeResult { Unchanged Changed(List(String)) } /// Main CLI entry point pub fn main() -> Nil { let args = argv.load().arguments case parse_args(args) { Ok(#(cmd, input, output, imports)) -> { case cmd { Generate -> run_generate(input, output, imports) Check -> run_check(input, output, imports) } } Error(snag.Snag(issue: "help", ..)) -> { print_usage() exit(0) } Error(error) -> { io.println_error("❌ Error: " <> error |> snag.pretty_print()) print_usage() exit(1) } } } fn parse_args( args: List(String), ) -> Result(#(Command, String, String, List(String))) { case args { [] | ["check"] -> parse_auto_mode(args) ["-h"] | ["--help"] -> snag.error("help") _ -> parse_manual_mode(args) } } fn parse_auto_mode( args: List(String), ) -> Result(#(Command, String, String, List(String))) { let cmd = case args { ["check"] -> Check _ -> Generate } case discover_project_structure() { Ok(#(input, output)) -> { Ok(#(cmd, input, output, ["."])) } Error(error) -> { case cmd { Check -> { // For check mode, we can proceed even without proto files // This allows checking in projects that don't have proto files yet Ok(#(cmd, ".", ".", ["."])) } Generate -> Error(error) |> snag.context("No gleam.toml found or no proto files detected") } } } } fn parse_manual_mode( args: List(String), ) -> Result(#(Command, String, String, List(String))) { let #(imports, remaining) = extract_imports(args, []) case remaining { [input, output] -> Ok(#(Generate, input, output, imports)) [input] -> Ok(#(Generate, input, ".", imports)) _ -> snag.error("Invalid arguments") } } fn extract_imports( args: List(String), acc: List(String), ) -> #(List(String), List(String)) { case args { [arg, ..rest] -> { case string.starts_with(arg, "-I") { True -> { let path = string.drop_start(arg, 2) extract_imports(rest, [path, ..acc]) } False -> #(list.reverse(acc), args) } } [] -> #(list.reverse(acc), []) } } fn discover_project_structure() -> Result(#(String, String)) { use name <- result.try( project.name() |> result.map_error(fn(_) { snag.new("Project name not found in gleam.toml") }), ) let proto_dir = filepath.join(project.src(), name <> "/proto") use proto_files <- result.try( simplifile.read_directory(proto_dir) |> snag.map_error(simplifile.describe_error), ) let proto_file = proto_dir <> "/" <> name <> ".proto" case list.any(proto_files, fn(f) { string.ends_with(f, ".proto") }) { True -> Ok(#(proto_file, proto_dir)) False -> snag.error("No proto files found") } } fn run_generate( input: String, output: String, import_paths: List(String), ) -> Nil { io.println("🔄 Generating proto files...") case generate_files(input, output, import_paths) { Ok(files) -> { io.println( "✅ Successfully generated " <> int.to_string(list.length(files)) <> " file(s):", ) list.each(files, fn(f) { io.println(" - " <> f) }) case shellout.command(run: "gleam", with: ["format"], in: ".", opt: []) { Ok(_) -> Nil Error(#(_, string)) -> io.println_error("❌ Failed to format files: " <> string) } } Error(err) -> { io.println_error("❌ Generation failed: " <> snag.pretty_print(err)) exit(1) } } } fn run_check(input: String, output: String, import_paths: List(String)) -> Nil { io.println("🔍 Checking proto file changes...") case check_changes(input, output, import_paths) { Ok(Unchanged) -> io.println("✅ Files are up to date") Ok(Changed(changes)) -> { io.println("⚠️ Changes detected:") list.each(changes, fn(c) { io.println(" - " <> c) }) io.println("💡 Run without 'check' to regenerate") exit(1) } Error(err) -> { io.println_error("❌ Check failed: " <> snag.pretty_print(err)) exit(1) } } } fn generate_files( input: String, output: String, import_paths: List(String), ) -> Result(List(String)) { let _ = simplifile.create_directory_all(output) use #(_, resolver) <- result.try(resolve_all_imports(input, import_paths)) let files = import_resolver.get_all_loaded_files(resolver) let registry = import_resolver.get_type_registry(resolver) let paths = list.map(files, fn(entry) { let #(path, content) = entry parser.Path(path, content) }) use generated <- result.try( codegen.generate_combined_proto_file(paths, registry, output) |> result.map_error(fn(err) { snag.new("Codegen failed: " <> err) }), ) Ok(list.map(generated, fn(entry) { entry.0 })) } fn check_changes( input: String, output: String, import_paths: List(String), ) -> Result(ChangeResult) { case generate_files(input, output, import_paths) { Ok(_) -> { // For simplicity, always report unchanged in check mode // A real implementation would compare timestamps or content hashes Ok(Unchanged) } Error(_) -> { // If there are no proto files to generate, that's still "unchanged" Ok(Unchanged) } } } fn resolve_all_imports( input: String, import_paths: List(String), ) -> Result(#(parser.ProtoFile, import_resolver.ImportResolver)) { let resolver = import_resolver.new() |> import_resolver.with_search_paths([".", ..import_paths]) import_resolver.resolve_imports(resolver, input) |> result.map_error(fn(err) { snag.new( "Import resolution failed: " <> import_resolver.describe_error(err), ) }) } fn print_usage() -> Nil { io.println("Protozoa Dev - Protocol Buffer Compiler CLI for Gleam") io.println("") io.println("Recommended Usage:") io.println( " gleam run -m protozoa_dev # Auto-detect and generate", ) io.println(" gleam run -m protozoa_dev check # Check for changes") io.println("") io.println("Advanced Usage:") io.println(" gleam run -m protozoa_dev input.proto [dir] # Manual mode") io.println( " gleam run -m protozoa_dev -Ipath input.proto # With import paths", ) io.println("") io.println("Options:") io.println( " -I # Add import search path", ) io.println(" --help # Show help") } @external(erlang, "erlang", "halt") fn exit(n: Int) -> Nil