use crate::ast::Literal;
use crate::types::*;
use std::fmt::Write;
pub fn emit_hcl(program: &TypedProgram, target: &str) -> String {
let mut emitter = HclEmitter::new(target);
emitter.emit_program(program);
emitter.output
}
pub fn emit_json(program: &TypedProgram, _target: &str) -> String {
serde_json::to_string_pretty(&program).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
}
struct HclEmitter {
output: String,
indent: usize,
target: String,
_resource_counter: usize,
loop_vars: Vec<String>,
}
impl HclEmitter {
fn new(target: &str) -> Self {
Self {
output: String::new(),
indent: 0,
target: target.to_string(),
loop_vars: Vec::new(),
_resource_counter: 0,
}
}
fn emit_program(&mut self, program: &TypedProgram) {
self.line("# Generated by Horkos - do not edit manually");
self.line(&format!("# Target: {}", self.target));
self.line("");
for statement in &program.statements {
self.emit_statement(statement);
self.line("");
}
}
fn emit_statement(&mut self, stmt: &TypedStatement) {
match stmt {
TypedStatement::Import { path, alias, .. } => {
self.line(&format!("# Imported from {} as {}", path, alias));
self.line("# NOTE: Imported resources are marked as Unverified");
}
TypedStatement::ValDecl { name, value, .. } => {
self.emit_val_decl(name, value);
}
TypedStatement::Module { name, body, .. } => {
self.line(&format!("# Module: {}", name));
for stmt in body {
self.emit_statement(stmt);
}
}
TypedStatement::Assert { message, .. } => {
self.line(&format!("# Verified: {}", message));
}
TypedStatement::HclBlock {
reason, content, ..
} => {
self.line(&format!("# HCL block: {}", reason));
self.output.push_str(content);
self.output.push('\n');
}
TypedStatement::Unsafe { reason, body, .. } => {
self.line(&format!("# Unsafe: {}", reason));
for stmt in body {
self.emit_statement(stmt);
}
}
}
}
fn emit_val_decl(&mut self, name: &str, value: &TypedExpr) {
match &value.kind {
TypedExprKind::FuncCall { callee, args } => {
if let TypedExprKind::MemberAccess { object, field } = &callee.kind {
match &object.resolved_type {
ResolvedType::List(_) => {
self.emit_list_method_call(name, object, field, args);
return;
}
ResolvedType::String => {
self.emit_string_method_call(name, object, field, args);
return;
}
_ => {}
}
}
self.emit_resource_call(name, callee, args);
}
TypedExprKind::List(elements) => {
self.line("locals {");
self.indent += 1;
self.line(&format!("{} = [", name));
self.indent += 1;
for elem in elements {
for _ in 0..self.indent {
self.output.push_str(" ");
}
self.emit_expr_inline(elem);
self.output.push_str(",\n");
}
self.indent -= 1;
self.line("]");
self.indent -= 1;
self.line("}");
}
TypedExprKind::Lambda { .. } => {
self.line(&format!("# val {} = <lambda>", name));
}
TypedExprKind::Binary { .. } | TypedExprKind::Unary { .. } => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(value);
self.output.push('\n');
self.indent -= 1;
self.line("}");
}
TypedExprKind::Literal(_) | TypedExprKind::Identifier(_) => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(value);
self.output.push('\n');
self.indent -= 1;
self.line("}");
}
TypedExprKind::MemberAccess { object, field } => {
if matches!(&object.resolved_type, ResolvedType::String) {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
match field.as_str() {
"toUpper" => {
write!(&mut self.output, "{} = upper(", name).unwrap();
self.emit_expr_inline(object);
self.output.push_str(")\n");
}
"toLower" => {
write!(&mut self.output, "{} = lower(", name).unwrap();
self.emit_expr_inline(object);
self.output.push_str(")\n");
}
"trim" => {
write!(&mut self.output, "{} = trimspace(", name).unwrap();
self.emit_expr_inline(object);
self.output.push_str(")\n");
}
"length" | "size" => {
write!(&mut self.output, "{} = length(", name).unwrap();
self.emit_expr_inline(object);
self.output.push_str(")\n");
}
_ => {
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(value);
self.output.push('\n');
}
}
self.indent -= 1;
self.line("}");
} else {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(value);
self.output.push('\n');
self.indent -= 1;
self.line("}");
}
}
TypedExprKind::Record(_) | TypedExprKind::If { .. } => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(value);
self.output.push('\n');
self.indent -= 1;
self.line("}");
}
TypedExprKind::Unsafe { body, reason } => {
self.line(&format!("# UNSAFE: {}", reason));
match &body.kind {
TypedExprKind::FuncCall { .. } => {
self.emit_val_decl(name, body);
}
TypedExprKind::List(_) => {
self.emit_val_decl(name, body);
}
TypedExprKind::Lambda { .. } => {
self.emit_val_decl(name, body);
}
TypedExprKind::Binary { .. } => {
self.emit_val_decl(name, body);
}
TypedExprKind::Unary { .. } => {
self.emit_val_decl(name, body);
}
TypedExprKind::Literal(_) => {
self.emit_val_decl(name, body);
}
TypedExprKind::Identifier(_) => {
self.emit_val_decl(name, body);
}
TypedExprKind::MemberAccess { .. }
| TypedExprKind::Record(_)
| TypedExprKind::Unsafe { .. }
| TypedExprKind::If { .. } => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = ", name).unwrap();
self.emit_expr_inline(body);
self.output.push('\n');
self.indent -= 1;
self.line("}");
}
}
}
}
}
fn emit_list_method_call(
&mut self,
name: &str,
list: &TypedExpr,
method: &str,
args: &[TypedArg],
) {
match method {
"map" => {
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } = &lambda_arg.value.kind {
let param = params.first().map(|p| p.as_str()).unwrap_or("item");
if let Some((module, function, call_args)) =
self.extract_resource_call(body)
{
self.emit_for_each_resource(
name, list, param, module, function, call_args,
);
} else {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
self.loop_vars.push(param.to_string());
write!(&mut self.output, "{} = [for {} in ", name, param).unwrap();
self.emit_expr_inline(list);
self.output.push_str(" : ");
self.emit_expr_inline(body.as_ref());
self.output.push_str("]\n");
self.loop_vars.pop();
self.indent -= 1;
self.line("}");
}
} else {
self.line("# TODO: map with non-lambda argument");
}
} else {
self.line("# TODO: map without arguments");
}
}
"filter" => {
self.line("locals {");
self.indent += 1;
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } = &lambda_arg.value.kind {
let param = params.first().map(|p| p.as_str()).unwrap_or("item");
self.loop_vars.push(param.to_string());
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = [for {} in ", name, param).unwrap();
self.emit_expr_inline(list);
write!(&mut self.output, " : {} if ", param).unwrap();
self.emit_expr_inline(body.as_ref());
self.output.push_str("]\n");
self.loop_vars.pop();
} else {
self.line("# TODO: filter with non-lambda argument");
}
} else {
self.line("# TODO: filter without arguments");
}
self.indent -= 1;
self.line("}");
}
"length" | "size" | "count" => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = length(", name).unwrap();
self.emit_expr_inline(list);
self.output.push_str(")\n");
self.indent -= 1;
self.line("}");
}
"concat" => {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = concat(", name).unwrap();
self.emit_expr_inline(list);
if let Some(other) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&other.value);
}
self.output.push_str(")\n");
self.indent -= 1;
self.line("}");
}
"any" => {
self.line("locals {");
self.indent += 1;
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } = &lambda_arg.value.kind {
let param = params.first().map(|p| p.as_str()).unwrap_or("item");
self.loop_vars.push(param.to_string());
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = anytrue([for {} in ", name, param).unwrap();
self.emit_expr_inline(list);
self.output.push_str(" : ");
self.emit_expr_inline(body.as_ref());
self.output.push_str("])\n");
self.loop_vars.pop();
} else {
self.line("# TODO: any with non-lambda argument");
}
} else {
self.line("# TODO: any without arguments");
}
self.indent -= 1;
self.line("}");
}
"all" => {
self.line("locals {");
self.indent += 1;
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } = &lambda_arg.value.kind {
let param = params.first().map(|p| p.as_str()).unwrap_or("item");
self.loop_vars.push(param.to_string());
for _ in 0..self.indent {
self.output.push_str(" ");
}
write!(&mut self.output, "{} = alltrue([for {} in ", name, param).unwrap();
self.emit_expr_inline(list);
self.output.push_str(" : ");
self.emit_expr_inline(body.as_ref());
self.output.push_str("])\n");
self.loop_vars.pop();
} else {
self.line("# TODO: all with non-lambda argument");
}
} else {
self.line("# TODO: all without arguments");
}
self.indent -= 1;
self.line("}");
}
_ => {
self.line(&format!("# TODO: list.{}(...)", method));
}
}
}
fn emit_string_method_call(
&mut self,
name: &str,
string: &TypedExpr,
method: &str,
args: &[TypedArg],
) {
self.line("locals {");
self.indent += 1;
for _ in 0..self.indent {
self.output.push_str(" ");
}
match method {
"concat" => {
write!(&mut self.output, "{} = format(\"%s%s\", ", name).unwrap();
self.emit_expr_inline(string);
if let Some(other) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&other.value);
}
self.output.push_str(")\n");
}
"toUpper" => {
write!(&mut self.output, "{} = upper(", name).unwrap();
self.emit_expr_inline(string);
self.output.push_str(")\n");
}
"toLower" => {
write!(&mut self.output, "{} = lower(", name).unwrap();
self.emit_expr_inline(string);
self.output.push_str(")\n");
}
"trim" => {
write!(&mut self.output, "{} = trimspace(", name).unwrap();
self.emit_expr_inline(string);
self.output.push_str(")\n");
}
"contains" => {
write!(&mut self.output, "{} = strcontains(", name).unwrap();
self.emit_expr_inline(string);
if let Some(substr) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&substr.value);
}
self.output.push_str(")\n");
}
"startsWith" => {
write!(&mut self.output, "{} = startswith(", name).unwrap();
self.emit_expr_inline(string);
if let Some(prefix) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&prefix.value);
}
self.output.push_str(")\n");
}
"endsWith" => {
write!(&mut self.output, "{} = endswith(", name).unwrap();
self.emit_expr_inline(string);
if let Some(suffix) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&suffix.value);
}
self.output.push_str(")\n");
}
"length" | "size" => {
write!(&mut self.output, "{} = length(", name).unwrap();
self.emit_expr_inline(string);
self.output.push_str(")\n");
}
_ => {
writeln!(&mut self.output, "# TODO: string.{}(...)", method).unwrap();
}
}
self.indent -= 1;
self.line("}");
}
fn extract_resource_call<'b>(
&self,
expr: &'b TypedExpr,
) -> Option<(&'b str, &'b str, &'b [TypedArg])> {
if let TypedExprKind::FuncCall { callee, args } = &expr.kind {
if let TypedExprKind::MemberAccess { object, field } = &callee.kind {
if let TypedExprKind::Identifier(module) = &object.kind {
match module.as_str() {
"S3" | "Network" | "IAM" | "EC2" => {
return Some((module.as_str(), field.as_str(), args.as_slice()));
}
_ => {}
}
}
}
}
None
}
fn emit_for_each_resource(
&mut self,
name: &str,
list: &TypedExpr,
iter_var: &str,
module: &str,
function: &str,
args: &[TypedArg],
) {
let resource_name = self.sanitize_resource_name(name);
let list_ref = match &list.kind {
TypedExprKind::Identifier(n) => format!("local.{}", n),
_ => {
self.line("# WARNING: Complex list expression in map - may need manual review");
"local.list".to_string()
}
};
match (module, function) {
("Network", "createSubnet") => {
self.line(&format!("resource \"aws_subnet\" \"{}\" {{", resource_name));
self.indent += 1;
self.line(&format!("for_each = toset({})", list_ref));
self.line("");
if let Some(vpc_ref) = args.iter().find(|a| a.name.as_deref() == Some("vpc")) {
self.line(&format!(
"vpc_id = {}",
self.expr_to_hcl_ref(&vpc_ref.value)
));
}
self.line("availability_zone = each.value");
if let Some(cidr_arg) = args.iter().find(|a| a.name.as_deref() == Some("cidr")) {
if let TypedExprKind::Literal(Literal::String(s)) = &cidr_arg.value.kind {
if s == "auto" {
self.line("# Auto CIDR - using cidrsubnet");
self.line(&format!("cidr_block = cidrsubnet(aws_vpc.vpc.cidr_block, 8, index(tolist({}), each.value))", list_ref));
} else {
self.line(&format!("cidr_block = \"{}\"", s));
}
}
}
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}-${{each.value}}\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
}
("Network", "createSecurityGroup") => {
self.line(&format!(
"resource \"aws_security_group\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("for_each = toset({})", list_ref));
self.line("");
self.line("name = \"${each.value}\"");
if let Some(vpc_ref) = args.iter().find(|a| a.name.as_deref() == Some("vpc")) {
self.line(&format!(
"vpc_id = {}",
self.expr_to_hcl_ref(&vpc_ref.value)
));
}
self.line("");
self.line("# Secure default: deny all ingress");
self.line("# Secure default: allow all egress");
self.line("egress {");
self.indent += 1;
self.line("from_port = 0");
self.line("to_port = 0");
self.line("protocol = \"-1\"");
self.line("cidr_blocks = [\"0.0.0.0/0\"]");
self.indent -= 1;
self.line("}");
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}-${{each.value}}\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
}
("S3", "createBucket") => {
self.line(&format!(
"resource \"aws_s3_bucket\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("for_each = toset({})", list_ref));
self.line("");
self.line("bucket = each.value");
self.line("");
self.line("tags = {");
self.indent += 1;
self.line("Name = each.value");
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.line("");
self.line(&format!("resource \"aws_s3_bucket_server_side_encryption_configuration\" \"{}_encryption\" {{", resource_name));
self.indent += 1;
self.line(&format!("for_each = aws_s3_bucket.{}", resource_name));
self.line("");
self.line("bucket = each.value.id");
self.line("");
self.line("rule {");
self.indent += 1;
self.line("apply_server_side_encryption_by_default {");
self.indent += 1;
self.line("sse_algorithm = \"AES256\"");
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.line("");
self.line(&format!(
"resource \"aws_s3_bucket_public_access_block\" \"{}_public_access\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("for_each = aws_s3_bucket.{}", resource_name));
self.line("");
self.line("bucket = each.value.id");
self.line("");
self.line("block_public_acls = true");
self.line("block_public_policy = true");
self.line("ignore_public_acls = true");
self.line("restrict_public_buckets = true");
self.indent -= 1;
self.line("}");
self.line("");
self.line(&format!(
"resource \"aws_s3_bucket_versioning\" \"{}_versioning\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("for_each = aws_s3_bucket.{}", resource_name));
self.line("");
self.line("bucket = each.value.id");
self.line("");
self.line("versioning_configuration {");
self.indent += 1;
self.line("status = \"Enabled\"");
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
}
_ => {
self.line(&format!(
"# TODO: for_each not yet implemented for {}.{}",
module, function
));
self.line(&format!(
"# Original: val {} = {}.map({} => {}.{}(...))",
name,
match &list.kind {
TypedExprKind::Identifier(n) => n.as_str(),
_ => "<list>",
},
iter_var,
module,
function
));
}
}
}
fn emit_resource_call(&mut self, name: &str, callee: &TypedExpr, args: &[TypedArg]) {
let (module, function) = match &callee.kind {
TypedExprKind::MemberAccess { object, field } => match &object.kind {
TypedExprKind::Identifier(m) => (m.as_str(), field.as_str()),
_ => ("unknown", "unknown"),
},
_ => ("unknown", "unknown"),
};
match (module, function) {
("S3", "createBucket") => {
self.emit_s3_bucket(name, args);
}
("Network", "createVpc") => {
self.emit_vpc(name, args);
}
("Network", "createSubnet") => {
self.emit_subnet(name, args);
}
("Network", "createInternetGateway") => {
self.emit_internet_gateway(name, args);
}
("Network", "createSecurityGroup") => {
self.emit_security_group(name, args);
}
("IAM", "createRole") => {
self.emit_iam_role(name, args);
}
("CloudWatch", "createLogGroup") => {
self.emit_log_group(name, args);
}
("RDS", "createDatabase") => {
self.emit_rds_database(name, args);
}
_ => {
self.line(&format!("# TODO: {}.{}(...)", module, function));
}
}
}
fn emit_s3_bucket(&mut self, name: &str, args: &[TypedArg]) {
let bucket_name = self
.get_string_arg(args, 0)
.unwrap_or_else(|| name.to_string());
let resource_name = self.sanitize_resource_name(name);
let public_access = self
.get_named_bool_arg(args, "publicAccess")
.unwrap_or(false);
let encrypted = self.get_named_bool_arg(args, "encrypted").unwrap_or(true);
let versioning = self.get_named_bool_arg(args, "versioning").unwrap_or(true);
let logging = self.get_named_bool_arg(args, "logging").unwrap_or(true);
if !versioning {
self.line(&format!(
"# Note: versioning disabled for {} (recommended: enabled)",
resource_name
));
}
if !logging {
self.line(&format!(
"# Note: access logging disabled for {} (recommended: enabled)",
resource_name
));
}
self.line(&format!(
"resource \"aws_s3_bucket\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = \"{}\"", bucket_name));
if let Some(tags_arg) = args.iter().find(|a| a.name.as_deref() == Some("tags")) {
self.emit_tags(&tags_arg.value);
} else {
self.emit_default_tags_block();
}
self.indent -= 1;
self.line("}");
self.line("");
let kms_key_id = self.get_named_string_arg(args, "kmsKeyId");
let sse_algorithm = self.get_named_string_arg(args, "sseAlgorithm");
if encrypted {
self.line(&format!(
"resource \"aws_s3_bucket_server_side_encryption_configuration\" \"{}_encryption\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = aws_s3_bucket.{}.id", resource_name));
self.line("");
self.line("rule {");
self.indent += 1;
self.line("apply_server_side_encryption_by_default {");
self.indent += 1;
let (algorithm, inferred) = match sse_algorithm {
Some(alg) => (alg, false),
None if kms_key_id.is_some() => ("aws:kms".to_string(), true),
None => ("AES256".to_string(), false),
};
if let Some(ref kms_key) = kms_key_id {
self.line(&format!("kms_master_key_id = {}", kms_key));
}
if inferred {
self.line("# aws:kms required when using kms_master_key_id");
}
self.line(&format!("sse_algorithm = \"{}\"", algorithm));
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.line("");
}
if !public_access {
self.line(&format!(
"resource \"aws_s3_bucket_public_access_block\" \"{}_public_access\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = aws_s3_bucket.{}.id", resource_name));
self.line("");
self.line("block_public_acls = true");
self.line("block_public_policy = true");
self.line("ignore_public_acls = true");
self.line("restrict_public_buckets = true");
self.indent -= 1;
self.line("}");
if versioning || logging {
self.line("");
}
}
if versioning {
self.line(&format!(
"resource \"aws_s3_bucket_versioning\" \"{}_versioning\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = aws_s3_bucket.{}.id", resource_name));
self.line("");
self.line("versioning_configuration {");
self.indent += 1;
self.line("status = \"Enabled\"");
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
if logging {
self.line("");
}
}
let log_bucket = self.get_named_string_arg(args, "logBucket");
let log_prefix = self.get_named_string_arg(args, "logPrefix");
let tags = self.get_named_string_arg(args, "tags");
if logging {
self.line(&format!(
"resource \"aws_s3_bucket_logging\" \"{}_logging\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = aws_s3_bucket.{}.id", resource_name));
self.line("");
let target =
log_bucket.unwrap_or_else(|| format!("aws_s3_bucket.{}.id", resource_name));
self.line(&format!("target_bucket = {}", target));
let prefix = log_prefix.unwrap_or_else(|| format!("{}/", resource_name));
self.line(&format!("target_prefix = \"{}\"", prefix));
self.indent -= 1;
self.line("}");
if tags.is_some() {
self.line("");
}
}
if let Some(ref tag_expr) = tags {
self.line(&format!(
"resource \"aws_s3_bucket_tagging\" \"{}_tags\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("bucket = aws_s3_bucket.{}.id", resource_name));
self.line("");
self.line(&format!("tags = {}", tag_expr));
self.indent -= 1;
self.line("}");
}
}
fn get_named_bool_arg(&self, args: &[TypedArg], name: &str) -> Option<bool> {
for arg in args {
if arg.name.as_deref() == Some(name) {
if let TypedExprKind::Literal(Literal::Bool(b)) = &arg.value.kind {
return Some(*b);
}
}
}
None
}
fn emit_vpc(&mut self, name: &str, args: &[TypedArg]) {
let cidr = self
.get_named_string_arg(args, "cidr")
.unwrap_or("10.0.0.0/16".to_string());
let resource_name = self.sanitize_resource_name(name);
self.line(&format!("resource \"aws_vpc\" \"{}\" {{", resource_name));
self.indent += 1;
self.line(&format!("cidr_block = \"{}\"", cidr));
self.line("");
self.line("enable_dns_hostnames = true");
self.line("enable_dns_support = true");
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
if let Some(flow_logs_arg) = self.get_named_arg(args, "flowLogs") {
self.line("");
self.emit_vpc_flow_log(&resource_name, flow_logs_arg);
}
}
fn emit_vpc_flow_log(&mut self, vpc_name: &str, destination: &TypedExpr) {
let dest_type = &destination.resolved_type;
self.line(&format!(
"resource \"aws_flow_log\" \"{}_flow_log\" {{",
vpc_name
));
self.indent += 1;
self.line(&format!("vpc_id = aws_vpc.{}.id", vpc_name));
self.line("traffic_type = \"ALL\"");
self.line("");
match dest_type {
crate::types::ResolvedType::Bucket | crate::types::ResolvedType::SecureBucket => {
let ref_str = self.expr_to_hcl_resource(destination);
self.line("log_destination_type = \"s3\"");
self.line(&format!("log_destination = {}.arn", ref_str));
}
crate::types::ResolvedType::LogGroup => {
let ref_str = self.expr_to_hcl_resource(destination);
self.line("log_destination_type = \"cloud-watch-logs\"");
self.line(&format!("log_destination = {}.arn", ref_str));
self.line("");
self.line("# IAM role for CloudWatch Logs");
self.line(&format!(
"iam_role_arn = aws_iam_role.{}_flow_log_role.arn",
vpc_name
));
}
_ => {
self.line(&format!(
"# Unknown flow log destination type: {:?}",
dest_type
));
self.line(&format!(
"log_destination = {}",
self.expr_to_hcl_ref(destination)
));
}
}
self.indent -= 1;
self.line("}");
if matches!(dest_type, crate::types::ResolvedType::LogGroup) {
self.line("");
self.emit_flow_log_iam_role(vpc_name);
}
}
fn emit_flow_log_iam_role(&mut self, vpc_name: &str) {
self.line(&format!(
"resource \"aws_iam_role\" \"{}_flow_log_role\" {{",
vpc_name
));
self.indent += 1;
self.line(&format!("name = \"{}-flow-log-role\"", vpc_name));
self.line("");
self.line("assume_role_policy = jsonencode({");
self.indent += 1;
self.line("Version = \"2012-10-17\"");
self.line("Statement = [{");
self.indent += 1;
self.line("Action = \"sts:AssumeRole\"");
self.line("Effect = \"Allow\"");
self.line("Principal = { Service = \"vpc-flow-logs.amazonaws.com\" }");
self.indent -= 1;
self.line("}]");
self.indent -= 1;
self.line("})");
self.indent -= 1;
self.line("}");
self.line("");
self.line(&format!(
"resource \"aws_iam_role_policy\" \"{}_flow_log_policy\" {{",
vpc_name
));
self.indent += 1;
self.line(&format!("name = \"{}-flow-log-policy\"", vpc_name));
self.line(&format!(
"role = aws_iam_role.{}_flow_log_role.id",
vpc_name
));
self.line("");
self.line("policy = jsonencode({");
self.indent += 1;
self.line("Version = \"2012-10-17\"");
self.line("Statement = [{");
self.indent += 1;
self.line("Action = [");
self.indent += 1;
self.line("\"logs:CreateLogGroup\",");
self.line("\"logs:CreateLogStream\",");
self.line("\"logs:PutLogEvents\",");
self.line("\"logs:DescribeLogGroups\",");
self.line("\"logs:DescribeLogStreams\"");
self.indent -= 1;
self.line("]");
self.line("Effect = \"Allow\"");
self.line("Resource = \"*\"");
self.indent -= 1;
self.line("}]");
self.indent -= 1;
self.line("})");
self.indent -= 1;
self.line("}");
}
fn emit_subnet(&mut self, name: &str, args: &[TypedArg]) {
let resource_name = self.sanitize_resource_name(name);
let is_public = self.get_named_bool_arg(args, "public").unwrap_or(false);
let map_public_ip = self
.get_named_bool_arg(args, "mapPublicIp")
.unwrap_or(false);
self.line(&format!("resource \"aws_subnet\" \"{}\" {{", resource_name));
self.indent += 1;
if let Some(vpc_ref) = self.get_named_arg(args, "vpc") {
self.line(&format!("vpc_id = {}", self.expr_to_hcl_ref(vpc_ref)));
}
if let Some(zone) = self.get_named_string_arg(args, "zone") {
self.line(&format!("availability_zone = \"{}\"", zone));
}
if let Some(cidr) = self.get_named_string_arg(args, "cidr") {
if cidr != "auto" {
self.line(&format!("cidr_block = \"{}\"", cidr));
}
}
if map_public_ip {
self.line("");
self.line("map_public_ip_on_launch = true");
}
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.line("");
if is_public {
if let Some(igw_ref) = self.get_named_arg(args, "gateway") {
self.line(&format!(
"resource \"aws_route_table\" \"{}_rt\" {{",
resource_name
));
self.indent += 1;
if let Some(vpc_ref) = self.get_named_arg(args, "vpc") {
self.line(&format!("vpc_id = {}", self.expr_to_hcl_ref(vpc_ref)));
}
self.line("");
self.line("route {");
self.indent += 1;
self.line("cidr_block = \"0.0.0.0/0\"");
self.line(&format!("gateway_id = {}", self.expr_to_hcl_ref(igw_ref)));
self.indent -= 1;
self.line("}");
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}-rt\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
self.line("");
self.line(&format!(
"resource \"aws_route_table_association\" \"{}_rta\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("subnet_id = aws_subnet.{}.id", resource_name));
self.line(&format!(
"route_table_id = aws_route_table.{}_rt.id",
resource_name
));
self.indent -= 1;
self.line("}");
}
}
}
fn emit_internet_gateway(&mut self, name: &str, args: &[TypedArg]) {
let resource_name = self.sanitize_resource_name(name);
self.line(&format!(
"resource \"aws_internet_gateway\" \"{}\" {{",
resource_name
));
self.indent += 1;
if let Some(vpc_ref) = self.get_named_arg(args, "vpc") {
self.line(&format!("vpc_id = {}", self.expr_to_hcl_ref(vpc_ref)));
}
if let Some(tags_arg) = args.iter().find(|a| a.name.as_deref() == Some("tags")) {
self.emit_tags(&tags_arg.value);
} else {
self.emit_default_tags_block();
}
self.indent -= 1;
self.line("}");
}
fn emit_security_group(&mut self, name: &str, args: &[TypedArg]) {
let resource_name = self.sanitize_resource_name(name);
self.line(&format!(
"resource \"aws_security_group\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("name = \"{}\"", name));
if let Some(vpc_ref) = self.get_named_arg(args, "vpc") {
self.line(&format!("vpc_id = {}", self.expr_to_hcl_ref(vpc_ref)));
}
self.line("");
self.line("# Secure default: deny all ingress");
self.line("# Secure default: allow all egress");
self.line("egress {");
self.indent += 1;
self.line("from_port = 0");
self.line("to_port = 0");
self.line("protocol = \"-1\"");
self.line("cidr_blocks = [\"0.0.0.0/0\"]");
self.indent -= 1;
self.line("}");
self.line("");
self.line("tags = {");
self.indent += 1;
self.line(&format!("Name = \"{}\"", name));
self.emit_default_tags();
self.indent -= 1;
self.line("}");
self.indent -= 1;
self.line("}");
}
fn emit_iam_role(&mut self, name: &str, args: &[TypedArg]) {
let resource_name = self.sanitize_resource_name(name);
self.line(&format!(
"resource \"aws_iam_role\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("name = \"{}\"", name));
self.line("");
if let Some(policy) = self.get_named_string_arg(args, "assumeRolePolicy") {
self.line("assume_role_policy = <<EOF");
self.line(&policy);
self.line("EOF");
} else {
self.line("# TODO: assume_role_policy");
}
self.indent -= 1;
self.line("}");
}
fn emit_log_group(&mut self, name: &str, args: &[TypedArg]) {
let log_name = self
.get_string_arg(args, 0)
.unwrap_or_else(|| name.to_string());
let resource_name = self.sanitize_resource_name(name);
self.line(&format!(
"resource \"aws_cloudwatch_log_group\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("name = \"{}\"", log_name));
if let Some(retention) = self.get_named_number_arg(args, "retentionDays") {
self.line(&format!("retention_in_days = {}", retention as i32));
}
if let Some(kms_key) = self.get_named_string_arg(args, "kmsKeyId") {
self.line(&format!("kms_key_id = {}", kms_key));
}
if let Some(tags) = self.get_named_string_arg(args, "tags") {
self.line(&format!("tags = {}", tags));
}
self.indent -= 1;
self.line("}");
}
fn emit_rds_database(&mut self, name: &str, args: &[TypedArg]) {
let identifier = self
.get_string_arg(args, 0)
.unwrap_or_else(|| name.to_string());
let resource_name = self.sanitize_resource_name(name);
let engine = self
.get_named_string_arg(args, "engine")
.unwrap_or_else(|| "postgres".to_string());
let engine_version = self
.get_named_string_arg(args, "engineVersion")
.unwrap_or_else(|| "14".to_string());
let username = self
.get_named_string_arg(args, "username")
.unwrap_or_else(|| "admin".to_string());
let instance_class = self
.get_named_string_arg(args, "instanceClass")
.unwrap_or_else(|| "db.t3.micro".to_string());
let allocated_storage = self
.get_named_number_arg(args, "allocatedStorage")
.unwrap_or(20.0) as i32;
let storage_type = self
.get_named_string_arg(args, "storageType")
.unwrap_or_else(|| "gp3".to_string());
let hardcoded_password = self.get_named_string_arg(args, "password");
let encryption = self.get_named_bool_arg(args, "encryption").unwrap_or(true);
let publicly_accessible = self
.get_named_bool_arg(args, "publiclyAccessible")
.unwrap_or(false);
let iam_auth = self.get_named_bool_arg(args, "iamAuth").unwrap_or(true);
let skip_final_snapshot = self
.get_named_bool_arg(args, "skipFinalSnapshot")
.unwrap_or(false);
let multi_az = self.get_named_bool_arg(args, "multiAz").unwrap_or(true);
let backup_retention = self
.get_named_number_arg(args, "backupRetention")
.unwrap_or(7.0) as i32;
let deletion_protection = self
.get_named_bool_arg(args, "deletionProtection")
.unwrap_or(true);
let performance_insights = self
.get_named_bool_arg(args, "performanceInsights")
.unwrap_or(true);
let auto_minor_upgrade = self
.get_named_bool_arg(args, "autoMinorVersionUpgrade")
.unwrap_or(true);
let enhanced_monitoring = self
.get_named_bool_arg(args, "enhancedMonitoring")
.unwrap_or(true);
if !multi_az {
self.line(&format!(
"# Note: Multi-AZ disabled for {} (recommended: enabled)",
resource_name
));
}
if backup_retention < 7 {
self.line(&format!(
"# Note: Backup retention {} days for {} (recommended: 7+)",
backup_retention, resource_name
));
}
if !deletion_protection {
self.line(&format!(
"# Note: Deletion protection disabled for {} (recommended: enabled)",
resource_name
));
}
if !performance_insights {
self.line(&format!(
"# Note: Performance Insights disabled for {} (recommended: enabled)",
resource_name
));
}
if !auto_minor_upgrade {
self.line(&format!(
"# Note: Auto minor version upgrade disabled for {} (recommended: enabled)",
resource_name
));
}
if !enhanced_monitoring {
self.line(&format!(
"# Note: Enhanced monitoring disabled for {} (recommended: enabled)",
resource_name
));
}
self.line(&format!(
"resource \"aws_db_instance\" \"{}\" {{",
resource_name
));
self.indent += 1;
self.line(&format!("identifier = \"{}\"", identifier));
self.line("");
self.line(&format!("engine = \"{}\"", engine));
self.line(&format!("engine_version = \"{}\"", engine_version));
self.line(&format!("instance_class = \"{}\"", instance_class));
self.line("");
self.line(&format!("allocated_storage = {}", allocated_storage));
self.line(&format!("storage_type = \"{}\"", storage_type));
self.line("");
self.line(&format!("username = \"{}\"", username));
if let Some(password) = &hardcoded_password {
self.line(&format!(
"password = {} # UNSAFE: hardcoded credentials",
password
));
} else {
self.line("manage_master_user_password = true # AWS Secrets Manager");
}
self.line("");
self.line(&format!("storage_encrypted = {}", encryption));
self.line(&format!("publicly_accessible = {}", publicly_accessible));
self.line(&format!(
"iam_database_authentication_enabled = {}",
iam_auth
));
if skip_final_snapshot {
self.line("skip_final_snapshot = true # UNSAFE: no final snapshot on deletion");
} else {
self.line(&format!(
"final_snapshot_identifier = \"{}-final\"",
identifier
));
}
self.line("");
if let Some(kms_key) = self.get_named_string_arg(args, "kmsKeyId") {
self.line(&format!("kms_key_id = {}", kms_key));
self.line("");
}
self.line(&format!("multi_az = {}", multi_az));
self.line(&format!("backup_retention_period = {}", backup_retention));
self.line(&format!("deletion_protection = {}", deletion_protection));
self.line(&format!(
"performance_insights_enabled = {}",
performance_insights
));
self.line(&format!(
"auto_minor_version_upgrade = {}",
auto_minor_upgrade
));
if enhanced_monitoring {
self.line("monitoring_interval = 60 # Enhanced monitoring");
}
self.line("");
if let Some(port) = self.get_named_number_arg(args, "port") {
self.line(&format!("port = {}", port as i32));
}
if let Some(window) = self.get_named_string_arg(args, "maintenanceWindow") {
self.line(&format!("maintenance_window = \"{}\"", window));
}
if let Some(window) = self.get_named_string_arg(args, "backupWindow") {
self.line(&format!("backup_window = \"{}\"", window));
}
if let Some(tags_arg) = args.iter().find(|a| a.name.as_deref() == Some("tags")) {
self.emit_tags(&tags_arg.value);
} else {
self.emit_default_tags_block();
}
self.indent -= 1;
self.line("}");
}
fn get_named_number_arg(&self, args: &[TypedArg], name: &str) -> Option<f64> {
args.iter()
.find(|arg| arg.name.as_deref() == Some(name))
.and_then(|arg| match &arg.value.kind {
TypedExprKind::Literal(crate::ast::Literal::Number(n)) => Some(*n),
_ => None,
})
}
fn line(&mut self, content: &str) {
for _ in 0..self.indent {
self.output.push_str(" ");
}
self.output.push_str(content);
self.output.push('\n');
}
fn emit_expr_inline(&mut self, expr: &TypedExpr) {
match &expr.kind {
TypedExprKind::Literal(lit) => match lit {
crate::ast::Literal::String(s) => {
let escaped = self.escape_string(s);
write!(&mut self.output, "\"{}\"", escaped).unwrap();
}
crate::ast::Literal::Number(n) => {
write!(&mut self.output, "{}", n).unwrap();
}
crate::ast::Literal::Bool(b) => {
write!(&mut self.output, "{}", b).unwrap();
}
},
TypedExprKind::Identifier(name) => {
if self.loop_vars.contains(name) {
self.output.push_str(name);
} else {
write!(&mut self.output, "local.{}", name).unwrap();
}
}
TypedExprKind::Unsafe { reason, body } => {
write!(&mut self.output, "/* UNSAFE: {} */ ", reason).unwrap();
self.emit_expr_inline(body);
}
TypedExprKind::If {
condition,
then_branch,
else_branch,
} => {
self.emit_expr_inline(condition);
self.output.push_str(" ? ");
self.emit_expr_inline(then_branch);
self.output.push_str(" : ");
self.emit_expr_inline(else_branch);
}
TypedExprKind::Binary { left, op, right } => {
self.output.push('(');
self.emit_expr_inline(left);
write!(&mut self.output, " {} ", op.name()).unwrap();
self.emit_expr_inline(right);
self.output.push(')');
}
TypedExprKind::Unary { op, operand } => {
let op_str = match op {
crate::ast::UnaryOp::Not => "!",
crate::ast::UnaryOp::Neg => "-",
};
self.output.push_str(op_str);
self.emit_expr_inline(operand);
}
TypedExprKind::Lambda { params, body } => {
write!(&mut self.output, "/* lambda({}) => */ ", params.join(", ")).unwrap();
self.emit_expr_inline(body);
}
TypedExprKind::List(elements) => {
self.output.push('[');
for (i, elem) in elements.iter().enumerate() {
if i > 0 {
self.output.push_str(", ");
}
self.emit_expr_inline(elem);
}
self.output.push(']');
}
TypedExprKind::MemberAccess { object, field } => {
if matches!(&object.resolved_type, ResolvedType::List(_)) {
match field.as_str() {
"length" | "size" | "count" => {
self.output.push_str("length(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
_ => {}
}
}
self.emit_expr_inline(object);
write!(&mut self.output, ".{}", field).unwrap();
}
TypedExprKind::FuncCall { callee, args } => {
if let TypedExprKind::MemberAccess { object, field } = &callee.kind {
if matches!(&object.resolved_type, ResolvedType::List(_)) {
match field.as_str() {
"filter" => {
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } =
&lambda_arg.value.kind
{
let param =
params.first().map(|p| p.as_str()).unwrap_or("x");
self.loop_vars.push(param.to_string());
self.output.push_str("[for ");
self.output.push_str(param);
self.output.push_str(" in ");
self.emit_expr_inline(object);
write!(&mut self.output, " : {} if ", param).unwrap();
self.emit_expr_inline(body);
self.output.push(']');
self.loop_vars.pop();
return;
}
}
}
"map" => {
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } =
&lambda_arg.value.kind
{
let param =
params.first().map(|p| p.as_str()).unwrap_or("x");
self.loop_vars.push(param.to_string());
self.output.push_str("[for ");
self.output.push_str(param);
self.output.push_str(" in ");
self.emit_expr_inline(object);
self.output.push_str(" : ");
self.emit_expr_inline(body);
self.output.push(']');
self.loop_vars.pop();
return;
}
}
}
"length" | "size" | "count" => {
self.output.push_str("length(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
"concat" => {
self.output.push_str("concat(");
self.emit_expr_inline(object);
if let Some(other) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&other.value);
}
self.output.push(')');
return;
}
"any" => {
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } =
&lambda_arg.value.kind
{
let param =
params.first().map(|p| p.as_str()).unwrap_or("x");
self.loop_vars.push(param.to_string());
self.output.push_str("anytrue([for ");
self.output.push_str(param);
self.output.push_str(" in ");
self.emit_expr_inline(object);
self.output.push_str(" : ");
self.emit_expr_inline(body);
self.output.push_str("])");
self.loop_vars.pop();
return;
}
}
}
"all" => {
if let Some(lambda_arg) = args.first() {
if let TypedExprKind::Lambda { params, body } =
&lambda_arg.value.kind
{
let param =
params.first().map(|p| p.as_str()).unwrap_or("x");
self.loop_vars.push(param.to_string());
self.output.push_str("alltrue([for ");
self.output.push_str(param);
self.output.push_str(" in ");
self.emit_expr_inline(object);
self.output.push_str(" : ");
self.emit_expr_inline(body);
self.output.push_str("])");
self.loop_vars.pop();
return;
}
}
}
_ => {}
}
}
if matches!(&object.resolved_type, ResolvedType::String) {
match field.as_str() {
"length" | "size" => {
self.output.push_str("length(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
"concat" => {
self.output.push_str("format(\"%s%s\", ");
self.emit_expr_inline(object);
if let Some(other) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&other.value);
}
self.output.push(')');
return;
}
"toUpper" => {
self.output.push_str("upper(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
"toLower" => {
self.output.push_str("lower(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
"trim" => {
self.output.push_str("trimspace(");
self.emit_expr_inline(object);
self.output.push(')');
return;
}
"contains" => {
self.output.push_str("strcontains(");
self.emit_expr_inline(object);
if let Some(substr) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&substr.value);
}
self.output.push(')');
return;
}
"startsWith" => {
self.output.push_str("startswith(");
self.emit_expr_inline(object);
if let Some(prefix) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&prefix.value);
}
self.output.push(')');
return;
}
"endsWith" => {
self.output.push_str("endswith(");
self.emit_expr_inline(object);
if let Some(suffix) = args.first() {
self.output.push_str(", ");
self.emit_expr_inline(&suffix.value);
}
self.output.push(')');
return;
}
_ => {}
}
}
}
self.emit_expr_inline(callee);
self.output.push('(');
for (i, arg) in args.iter().enumerate() {
if i > 0 {
self.output.push_str(", ");
}
if let Some(name) = &arg.name {
write!(&mut self.output, "{} = ", name).unwrap();
}
self.emit_expr_inline(&arg.value);
}
self.output.push(')');
}
TypedExprKind::Record(fields) => {
self.output.push('{');
for (i, field) in fields.iter().enumerate() {
if i > 0 {
self.output.push_str(", ");
}
write!(&mut self.output, "{} = ", field.name).unwrap();
self.emit_expr_inline(&field.value);
}
self.output.push('}');
}
}
}
fn expr_to_hcl_ref(&self, expr: &TypedExpr) -> String {
let base = self.expr_to_hcl_resource(expr);
format!("{}.id", base)
}
fn expr_to_hcl_resource(&self, expr: &TypedExpr) -> String {
let resource_name = match &expr.kind {
TypedExprKind::Identifier(name) => self.sanitize_resource_name(name),
TypedExprKind::MemberAccess { object, field } => {
match &object.kind {
TypedExprKind::Identifier(obj_name) => {
return format!("{}.{}", obj_name, field);
}
_ => return "/* ref */".to_string(),
}
}
_ => return "/* ref */".to_string(),
};
let tf_resource_type = self.type_to_terraform_resource(&expr.resolved_type);
format!("{}.{}", tf_resource_type, resource_name)
}
fn type_to_terraform_resource(&self, ty: &ResolvedType) -> &'static str {
match ty {
ResolvedType::Vpc => "aws_vpc",
ResolvedType::Subnet => "aws_subnet",
ResolvedType::SecurityGroup => "aws_security_group",
ResolvedType::InternetGateway => "aws_internet_gateway",
ResolvedType::SecureBucket | ResolvedType::Bucket => "aws_s3_bucket",
ResolvedType::IamRole => "aws_iam_role",
ResolvedType::IamPolicy => "aws_iam_policy",
ResolvedType::LogGroup => "aws_cloudwatch_log_group",
_ => "/* unknown_resource */",
}
}
fn get_string_arg(&self, args: &[TypedArg], index: usize) -> Option<String> {
args.get(index).and_then(|arg| match &arg.value.kind {
TypedExprKind::Literal(crate::ast::Literal::String(s)) => Some(s.clone()),
_ => None,
})
}
fn get_named_string_arg(&self, args: &[TypedArg], name: &str) -> Option<String> {
args.iter()
.find(|arg| arg.name.as_deref() == Some(name))
.and_then(|arg| match &arg.value.kind {
TypedExprKind::Literal(crate::ast::Literal::String(s)) => Some(s.clone()),
_ => None,
})
}
fn get_named_arg<'a>(&self, args: &'a [TypedArg], name: &str) -> Option<&'a TypedExpr> {
args.iter()
.find(|arg| arg.name.as_deref() == Some(name))
.map(|arg| &arg.value)
}
fn sanitize_resource_name(&self, name: &str) -> String {
name.replace(['-', '.'], "_")
}
fn escape_string(&self, s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn emit_tags(&mut self, expr: &TypedExpr) {
if let TypedExprKind::Record(fields) = &expr.kind {
self.line("");
self.line("tags = {");
self.indent += 1;
for field in fields {
if let TypedExprKind::Literal(Literal::String(value)) = &field.value.kind {
self.line(&format!(
"{} = \"{}\"",
field.name,
self.escape_string(value)
));
}
}
self.emit_default_tags();
self.indent -= 1;
self.line("}");
}
}
fn emit_default_tags_block(&mut self) {
self.line("");
self.line("tags = {");
self.indent += 1;
self.emit_default_tags();
self.indent -= 1;
self.line("}");
}
fn emit_default_tags(&mut self) {
self.line("ManagedBy = \"Horkos\"");
self.line(&format!(
"HorkosVersion = \"{}\"",
env!("CARGO_PKG_VERSION")
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{compile_source, CompileOptions};
#[test]
fn test_empty_program() {
let program = TypedProgram { statements: vec![] };
let hcl = emit_hcl(&program, "test");
assert!(hcl.contains("Generated by Horkos"));
assert!(hcl.contains("Target: test"));
}
#[test]
fn test_s3_bucket_generates_correct_hcl() {
let source = r#"val bucket = S3.createBucket("my-data")"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(
result.contains("aws_s3_bucket"),
"should generate S3 bucket resource"
);
assert!(
result.contains("my_data") || result.contains("my-data"),
"should include bucket name"
);
assert!(
result.contains("block_public_acls"),
"should have public access block"
);
assert!(
result.contains("Enabled") || result.contains("versioning"),
"should have versioning: {}",
result
);
}
#[test]
fn test_vpc_generates_correct_hcl() {
let source = r#"
val logBucket = S3.createBucket("vpc-logs")
val vpc = Network.createVpc("main", cidr: "10.0.0.0/16", flowLogs: logBucket)
"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(result.contains("aws_vpc"), "should generate VPC resource");
assert!(result.contains("10.0.0.0/16"), "should include CIDR block");
assert!(result.contains("aws_flow_log"), "should generate flow log");
}
#[test]
fn test_vpc_without_flowlogs_requires_unsafe() {
let source = r#"val vpc = Network.createVpc("main", cidr: "10.0.0.0/16")"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options);
assert!(
result.is_err(),
"VPC without flowLogs should require unsafe"
);
}
#[test]
fn test_security_group_generates_correct_hcl() {
let source = r#"
val logBucket = S3.createBucket("vpc-logs")
val vpc = Network.createVpc("main", cidr: "10.0.0.0/16", flowLogs: logBucket)
val sg = Network.createSecurityGroup(vpc: vpc, name: "web")
"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(
result.contains("aws_security_group"),
"should generate security group"
);
}
#[test]
fn test_list_generates_terraform_list() {
let source = r#"val zones = ["us-east-1a", "us-east-1b"]"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(result.contains("locals"), "should generate locals block");
assert!(result.contains("us-east-1a"), "should include list items");
}
#[test]
fn test_map_generates_for_expression() {
let source = r#"
val zones = ["us-east-1a", "us-east-1b"]
val upper = zones.map(z => z)
"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(result.contains("for"), "should generate for expression");
}
#[test]
fn test_binary_expression_in_locals() {
let source = r#"val total = 1 + 2 * 3"#;
let options = CompileOptions::default();
let result = compile_source(source, "test.hk", &options).unwrap();
assert!(result.contains("locals"), "should generate locals");
assert!(
result.contains("1 + 2 * 3") || result.contains("1 + (2 * 3)"),
"should preserve expression"
);
}
}