use std::io::Write;
use std::io::{self};
use crate::codegen::collapse_lines;
use crate::codegen::combined_args;
use crate::codegen::command_type_prefix;
use crate::codegen::ensure_unique_command_prefixes;
use crate::codegen::inherited_globals;
use crate::codegen::is_grouped_repeated;
use crate::codegen::is_required;
use crate::codegen::option_token;
use crate::codegen::output_schema;
use crate::codegen::pascal_case;
use crate::codegen::quote_double;
use crate::codegen::safe_identifier;
use crate::generate::Generator;
use crate::generate::OutputContractGeneration;
use crate::model::ArgKind;
use crate::model::ArgSpec;
use crate::model::CliSpec;
use crate::model::CommandSpec;
use crate::model::OutputEncoding;
use crate::model::OutputMode;
use crate::model::ValueType;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KotlinOptions {
pub module_name: Option<String>,
pub package_name: Option<String>,
pub output_contracts: OutputContractGeneration,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Kotlin {
options: KotlinOptions,
}
impl Kotlin {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_options(options: KotlinOptions) -> Self {
Self { options }
}
#[must_use]
pub fn module_name(mut self, module_name: impl Into<String>) -> Self {
self.options.module_name = Some(module_name.into());
self
}
#[must_use]
pub fn package_name(mut self, package_name: impl Into<String>) -> Self {
self.options.package_name = Some(package_name.into());
self
}
#[must_use]
pub fn output_contracts(mut self) -> Self {
self.options.output_contracts = OutputContractGeneration::Emit;
self
}
#[must_use]
pub fn without_output_contracts(mut self) -> Self {
self.options.output_contracts = OutputContractGeneration::Omit;
self
}
}
impl Generator for Kotlin {
fn file_name(&self, bin_name: &str) -> String {
let stem = self
.options
.module_name
.as_deref()
.map(pascal_case)
.unwrap_or_else(|| format!("{}Bindings", pascal_case(bin_name)));
format!("{stem}.kt")
}
fn generate(&self, spec: &CliSpec, buf: &mut dyn Write) -> io::Result<()> {
ensure_unique_command_prefixes(spec)?;
let mut output = String::new();
render_module(spec, &self.options, &mut output);
buf.write_all(output.as_bytes())
}
}
fn render_module(spec: &CliSpec, options: &KotlinOptions, output: &mut String) {
output.push_str("// Generated by clap_types. Do not edit by hand.\n");
if let Some(package_name) = options.package_name.as_deref() {
output.push_str(&format!("package {package_name}\n\n"));
}
output.push_str(&format!(
"const val PROGRAM: String = {}\n\n",
quote_double(&spec.bin_name)
));
render_invocation(output);
render_helpers(output);
if options.output_contracts.is_enabled() {
render_output_contracts(spec, output);
}
let mut path = Vec::<String>::new();
let inherited: Vec<&ArgSpec> = Vec::new();
render_command_tree(&spec.root, &mut path, &inherited, output);
}
fn render_invocation(output: &mut String) {
output.push_str(
r#"data class CommandInvocation(
val program: String,
val args: List<String>,
) {
fun argv(): List<String> = listOf(program) + args
fun processBuilder(): ProcessBuilder = ProcessBuilder(argv())
fun start(): Process = processBuilder().start()
}
"#,
);
}
fn render_helpers(output: &mut String) {
output.push_str(
r#"private fun stringify(value: Any): String =
when (value) {
is Boolean -> if (value) "true" else "false"
else -> value.toString()
}
private fun pushOne(argv: MutableList<String>, value: Any) {
argv.add(stringify(value))
}
private fun pushValues(argv: MutableList<String>, values: Iterable<*>, name: String) {
for (value in values) {
val item = requireNotNull(value) { "CLI argument '$name' values cannot be null" }
pushOne(argv, item)
}
}
private fun pushOption(argv: MutableList<String>, option: String, value: Any) {
argv.add(option)
pushOne(argv, value)
}
private fun pushOptionValues(argv: MutableList<String>, option: String, values: Iterable<*>) {
argv.add(option)
pushValues(argv, values, option)
}
private fun pushRepeatedOption(argv: MutableList<String>, option: String, values: Iterable<*>) {
for (value in values) {
val item = requireNotNull(value) { "CLI option '$option' values cannot be null" }
pushOption(argv, option, item)
}
}
private fun pushGroupedRepeatedOption(
argv: MutableList<String>,
option: String,
groups: Iterable<Iterable<*>>,
) {
for (group in groups) {
pushOptionValues(argv, option, group)
}
}
"#,
);
}
fn render_output_contracts(spec: &CliSpec, output: &mut String) {
output.push_str(
r#"enum class OutputEncoding {
JSON,
JSON_LINES,
TEXT,
}
enum class OutputMode {
BUFFERED,
STREAMING,
INTERACTIVE,
}
data class OutputContract(
val commandPath: List<String>,
val encoding: OutputEncoding,
val mode: OutputMode,
val typeName: String,
val schema: String? = null,
)
sealed class ParsedOutput {
data class Json(val value: String) : ParsedOutput()
data class JsonLines(val values: List<String>) : ParsedOutput()
data class Text(val value: String) : ParsedOutput()
data class Interactive(val value: String) : ParsedOutput()
}
val OUTPUT_CONTRACTS: List<OutputContract> = listOf(
"#,
);
for contract in &spec.outputs {
output.push_str(" OutputContract(\n");
output.push_str(&format!(
" commandPath = listOf({}),\n",
contract
.command_path
.iter()
.map(|piece| quote_double(piece))
.collect::<Vec<_>>()
.join(", ")
));
output.push_str(&format!(
" encoding = OutputEncoding.{},\n",
kotlin_output_encoding(contract.encoding)
));
output.push_str(&format!(
" mode = OutputMode.{},\n",
kotlin_output_mode(contract.mode)
));
output.push_str(&format!(
" typeName = {},\n",
quote_double(&contract.type_name)
));
if let Some(schema) = contract.schema.as_ref().map(output_schema) {
output.push_str(&format!(" schema = {},\n", quote_double(schema)));
}
output.push_str(" ),\n");
}
output.push_str(
r#")
fun parseOutput(contract: OutputContract, stdout: String): ParsedOutput {
if (contract.mode == OutputMode.INTERACTIVE) {
return ParsedOutput.Interactive(stdout)
}
return when (contract.encoding) {
OutputEncoding.JSON -> ParsedOutput.Json(stdout)
OutputEncoding.JSON_LINES ->
ParsedOutput.JsonLines(stdout.lineSequence().filter { it.isNotEmpty() }.toList())
OutputEncoding.TEXT -> ParsedOutput.Text(stdout)
}
}
"#,
);
}
fn render_command_tree(
command: &CommandSpec,
path: &mut Vec<String>,
inherited: &[&ArgSpec],
output: &mut String,
) {
render_command(command, path, inherited, output);
let next_inherited = inherited_globals(inherited, command);
for subcommand in &command.subcommands {
path.push(subcommand.name.clone());
render_command_tree(subcommand, path, &next_inherited, output);
path.pop();
}
}
fn render_command(
command: &CommandSpec,
path: &[String],
inherited: &[&ArgSpec],
output: &mut String,
) {
let type_prefix = command_type_prefix(command, path);
let args_name = format!("{type_prefix}Args");
let build_fn = format!("build{type_prefix}Command");
let command_fn = format!("{}Command", lower_camel(&type_prefix));
let all_args = combined_args(inherited, command);
let has_required = all_args.iter().copied().any(is_required);
render_enums(&type_prefix, &all_args, output);
render_args_class(command, &type_prefix, &args_name, &all_args, output);
output.push('\n');
render_build_function(
command,
path,
inherited,
&args_name,
&build_fn,
has_required,
output,
);
output.push('\n');
let args_param = if has_required {
format!("args: {args_name}")
} else {
format!("args: {args_name} = {args_name}()")
};
output.push_str(&format!(
"fun {command_fn}({args_param}, program: String = PROGRAM): CommandInvocation =\n"
));
output.push_str(&format!(
" CommandInvocation(program, {build_fn}(args))\n\n"
));
}
fn render_enums(type_prefix: &str, args: &[&ArgSpec], output: &mut String) {
for &arg in args {
if arg.possible_values.is_empty() {
continue;
}
let enum_name = enum_name(type_prefix, arg);
output.push_str(&format!(
"enum class {enum_name}(private val wireValue: String) {{\n"
));
for value in &arg.possible_values {
output.push_str(&format!(
" {}({}),\n",
enum_variant(&value.name),
quote_double(&value.name)
));
}
output.push_str(" ;\n\n");
output.push_str(" override fun toString(): String = wireValue\n");
output.push_str("}\n\n");
}
}
fn render_args_class(
command: &CommandSpec,
type_prefix: &str,
args_name: &str,
args: &[&ArgSpec],
output: &mut String,
) {
if let Some(doc) = command.about.as_deref().or(command.long_about.as_deref()) {
output.push_str(&format!("/** {} */\n", doc_comment(doc)));
}
if args.is_empty() {
output.push_str(&format!("class {args_name}\n"));
return;
}
output.push_str(&format!("data class {args_name}(\n"));
for arg in sorted_constructor_args(args) {
for comment in field_comments(arg) {
output.push_str(&format!(" /** {comment} */\n"));
}
output.push_str(&format!(
" val {}: {}{},\n",
property_name(arg),
kotlin_type(type_prefix, arg),
kotlin_default(arg)
));
}
output.push_str(")\n");
}
fn sorted_constructor_args<'a>(args: &[&'a ArgSpec]) -> Vec<&'a ArgSpec> {
let mut sorted = args.to_vec();
sorted.sort_by_key(|arg| !is_required(arg));
sorted
}
fn render_build_function(
command: &CommandSpec,
path: &[String],
inherited: &[&ArgSpec],
args_name: &str,
build_fn: &str,
has_required: bool,
output: &mut String,
) {
let args_param = if has_required {
format!("args: {args_name}")
} else {
format!("args: {args_name} = {args_name}()")
};
output.push_str(&format!("fun {build_fn}({args_param}): List<String> {{\n"));
output.push_str(" val argv = mutableListOf<String>()\n");
for &arg in inherited {
render_arg_builder(arg, output);
}
for token in path {
output.push_str(&format!(" argv.add({})\n", quote_double(token)));
}
for arg in &command.args {
if !inherited.iter().any(|existing| existing.id == arg.id) {
render_arg_builder(arg, output);
}
}
output.push_str(" return argv\n");
output.push_str("}\n");
}
fn render_arg_builder(arg: &ArgSpec, output: &mut String) {
let prop = property_name(arg);
let option = option_token(arg);
let accessor = format!("args.{prop}");
match arg.kind {
ArgKind::FlagTrue => {
if let Some(option) = option {
output.push_str(&format!(" if ({accessor}) {{\n"));
output.push_str(&format!(" argv.add({})\n", quote_double(&option)));
output.push_str(" }\n");
}
}
ArgKind::FlagFalse => {
if let Some(option) = option {
output.push_str(&format!(" if ({accessor} == false) {{\n"));
output.push_str(&format!(" argv.add({})\n", quote_double(&option)));
output.push_str(" }\n");
}
}
ArgKind::Counter => {
if let Some(option) = option {
output.push_str(&format!(" repeat({accessor}) {{\n"));
output.push_str(&format!(" argv.add({})\n", quote_double(&option)));
output.push_str(" }\n");
}
}
ArgKind::Option => {
if let Some(option) = option {
render_value_builder(arg, &accessor, &option, true, output);
}
}
ArgKind::Positional => render_value_builder(arg, &accessor, "", false, output),
}
}
fn render_value_builder(
arg: &ArgSpec,
accessor: &str,
option: &str,
is_option: bool,
output: &mut String,
) {
let option = quote_double(option);
if is_grouped_repeated(arg) {
if is_option {
output.push_str(&format!(" if ({accessor}.isNotEmpty()) {{\n"));
output.push_str(&format!(
" pushGroupedRepeatedOption(argv, {option}, {accessor})\n"
));
output.push_str(" }\n");
} else {
output.push_str(&format!(" for (group in {accessor}) {{\n"));
output.push_str(&format!(
" pushValues(argv, group, {})\n",
quote_double(&arg.id)
));
output.push_str(" }\n");
}
} else if arg.value.repeated || arg.value.arity.allows_multiple() {
if is_option {
output.push_str(&format!(" if ({accessor}.isNotEmpty()) {{\n"));
if arg.value.repeated {
output.push_str(&format!(
" pushRepeatedOption(argv, {option}, {accessor})\n"
));
} else {
output.push_str(&format!(
" pushOptionValues(argv, {option}, {accessor})\n"
));
}
output.push_str(" }\n");
} else {
output.push_str(&format!(
" pushValues(argv, {accessor}, {})\n",
quote_double(&arg.id)
));
}
} else if is_required(arg) {
if is_option {
output.push_str(&format!(" pushOption(argv, {option}, {accessor})\n"));
} else {
output.push_str(&format!(" pushOne(argv, {accessor})\n"));
}
} else if is_option {
output.push_str(&format!(" {accessor}?.let {{ value ->\n"));
output.push_str(&format!(" pushOption(argv, {option}, value)\n"));
output.push_str(" }\n");
} else {
output.push_str(&format!(" {accessor}?.let {{ value ->\n"));
output.push_str(" pushOne(argv, value)\n");
output.push_str(" }\n");
}
}
fn kotlin_type(type_prefix: &str, arg: &ArgSpec) -> String {
let scalar = kotlin_scalar_type(type_prefix, arg);
match arg.kind {
ArgKind::FlagTrue => "Boolean".to_owned(),
ArgKind::FlagFalse => "Boolean?".to_owned(),
ArgKind::Counter => "Int".to_owned(),
ArgKind::Option | ArgKind::Positional => {
if is_grouped_repeated(arg) {
format!("List<List<{scalar}>>")
} else if arg.value.repeated || arg.value.arity.allows_multiple() {
format!("List<{scalar}>")
} else if is_required(arg) {
scalar
} else {
format!("{scalar}?")
}
}
}
}
fn kotlin_scalar_type(type_prefix: &str, arg: &ArgSpec) -> String {
if !arg.possible_values.is_empty() {
return enum_name(type_prefix, arg);
}
match arg.kind {
ArgKind::FlagTrue | ArgKind::FlagFalse => "Boolean".to_owned(),
ArgKind::Counter => "Int".to_owned(),
ArgKind::Option | ArgKind::Positional => match arg.value.ty {
ValueType::Bool => "Boolean".to_owned(),
ValueType::Integer => "Int".to_owned(),
ValueType::BigInteger => "Long".to_owned(),
ValueType::Float => "Double".to_owned(),
ValueType::Unknown | ValueType::String | ValueType::OsString | ValueType::Path => {
"String".to_owned()
}
},
}
}
fn kotlin_default(arg: &ArgSpec) -> &'static str {
match arg.kind {
ArgKind::FlagTrue => " = false",
ArgKind::FlagFalse => " = null",
ArgKind::Counter => " = 0",
ArgKind::Option | ArgKind::Positional => {
if is_grouped_repeated(arg) || arg.value.repeated || arg.value.arity.allows_multiple() {
if is_required(arg) {
""
} else {
" = emptyList()"
}
} else if is_required(arg) {
""
} else {
" = null"
}
}
}
}
fn property_name(arg: &ArgSpec) -> String {
safe_identifier(&lower_camel(&arg.id), is_reserved)
}
fn enum_name(type_prefix: &str, arg: &ArgSpec) -> String {
format!("{type_prefix}{}", pascal_case(&arg.id))
}
fn enum_variant(value: &str) -> String {
safe_identifier(&pascal_case(value), is_reserved)
}
fn field_comments(arg: &ArgSpec) -> Vec<String> {
let mut comments = Vec::new();
if let Some(help) = arg.help.as_deref().or(arg.long_help.as_deref()) {
comments.push(doc_comment(help));
}
if !arg.defaults.is_empty() {
comments.push(format!("Clap default: {}.", arg.defaults.join(", ")));
}
comments
}
fn lower_camel(input: &str) -> String {
let pascal = pascal_case(input);
let mut chars = pascal.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_lowercase(), chars.as_str()),
None => "value".to_owned(),
}
}
fn doc_comment(input: &str) -> String {
collapse_lines(input).replace("*/", "* /")
}
fn kotlin_output_encoding(encoding: OutputEncoding) -> &'static str {
match encoding {
OutputEncoding::Json => "JSON",
OutputEncoding::JsonLines => "JSON_LINES",
OutputEncoding::Text => "TEXT",
}
}
fn kotlin_output_mode(mode: OutputMode) -> &'static str {
match mode {
OutputMode::Buffered => "BUFFERED",
OutputMode::Streaming => "STREAMING",
OutputMode::Interactive => "INTERACTIVE",
}
}
fn is_reserved(identifier: &str) -> bool {
RESERVED.contains(&identifier)
}
const RESERVED: &[&str] = &[
"as",
"break",
"class",
"continue",
"do",
"else",
"false",
"for",
"fun",
"if",
"in",
"interface",
"is",
"null",
"object",
"package",
"return",
"super",
"this",
"throw",
"true",
"try",
"typealias",
"typeof",
"val",
"var",
"when",
"while",
];
#[cfg(test)]
mod tests {
use clap::Arg;
use clap::ArgAction;
use clap::Command;
use clap::value_parser;
use crate::Kotlin;
use crate::generate;
#[test]
fn generates_typed_kotlin_builders() {
let cmd = Command::new("demo-tool")
.arg(Arg::new("verbose").short('v').action(ArgAction::Count))
.subcommand(
Command::new("run")
.arg(Arg::new("target").required(true))
.arg(Arg::new("mode").long("mode").value_parser(["fast", "slow"]))
.arg(
Arg::new("threads")
.long("threads")
.value_parser(value_parser!(u16)),
),
);
let mut output = Vec::<u8>::new();
generate(Kotlin::new(), &cmd, "demo-tool", &mut output).expect("kotlin generation works");
let output = String::from_utf8(output).expect("kotlin is utf-8");
assert!(output.contains("const val PROGRAM: String = \"demo-tool\""));
assert!(output.contains("data class RunArgs("));
assert!(output.contains("enum class RunMode"));
assert!(output.contains("val target: String,"));
assert!(output.contains("val threads: Int? = null,"));
assert!(output.contains("argv.add(\"run\")"));
assert!(output.contains("pushOption(argv, \"--threads\", value)"));
}
#[test]
fn emits_output_contracts_when_enabled() {
let spec = crate::CliSpec {
bin_name: "demo".to_owned(),
root: crate::CommandSpec {
name: "demo".to_owned(),
display_name: None,
about: None,
long_about: None,
args: Vec::new(),
subcommands: Vec::new(),
},
outputs: vec![crate::OutputSpec {
command_path: vec!["watch".to_owned()],
encoding: crate::OutputEncoding::JsonLines,
mode: crate::OutputMode::Streaming,
type_name: "WatchEvent".to_owned(),
schema: Some(crate::OutputSchema::JsonSchema(
"{\"type\":\"object\"}".to_owned(),
)),
}],
};
let mut output = Vec::<u8>::new();
crate::Generator::generate(&Kotlin::new().output_contracts(), &spec, &mut output)
.expect("kotlin generation works");
let output = String::from_utf8(output).expect("kotlin is utf-8");
assert!(output.contains("val OUTPUT_CONTRACTS: List<OutputContract>"));
assert!(output.contains("encoding = OutputEncoding.JSON_LINES"));
assert!(output.contains("fun parseOutput("));
}
#[test]
fn variadic_positionals_emit_required_lists_in_order() {
let cmd = Command::new("demo-tool").subcommand(
Command::new("copy")
.arg(Arg::new("source").required(true))
.arg(
Arg::new("mode")
.long("mode")
.value_parser(["fast", "safe"])
.action(ArgAction::Set),
)
.arg(Arg::new("paths").num_args(1..).required(true)),
);
let mut output = Vec::<u8>::new();
generate(Kotlin::new(), &cmd, "demo-tool", &mut output).expect("kotlin generation works");
let output = String::from_utf8(output).expect("kotlin is utf-8");
assert!(
output.contains("val paths: List<String>,"),
"expected variadic positional list type, got:\n{output}"
);
assert!(
output.contains("pushValues(argv, args.paths, \"paths\")"),
"expected variadic positional builder, got:\n{output}"
);
let source_index = output
.find("pushOne(argv, args.source)")
.expect("source positional builder");
let mode_index = output.find("args.mode?.let").expect("mode option builder");
let paths_index = output
.find("pushValues(argv, args.paths, \"paths\")")
.expect("variadic positional builder");
assert!(source_index < mode_index);
assert!(mode_index < paths_index);
}
}