use crate::swiftgen_style;
use std::collections::HashMap;
use std::path::Path;
use anyhow::{anyhow, Context as _};
use crepuscularity_core::ast::{Element, ForBlock, IfBlock, MatchBlock, Node, TextPart};
pub fn run(
view_path: &Path,
out_dir: &Path,
view_name: &str,
context_type: &str,
) -> anyhow::Result<()> {
let source = std::fs::read_to_string(view_path)
.with_context(|| format!("read {}", view_path.display()))?;
let nodes = crepuscularity_core::parser::parse_template(&source)
.map_err(|e| anyhow!("parse {}: {e}", view_path.display()))?;
let swift = emit_view(&nodes, view_name, context_type)?;
std::fs::create_dir_all(out_dir)?;
let out = out_dir.join(format!("{}.swift", view_name));
std::fs::write(&out, swift)?;
eprintln!(" {} → {}", view_path.display(), out.display());
Ok(())
}
#[derive(Clone)]
struct GenCtx {
loop_vars: Vec<String>,
lets: HashMap<String, String>,
}
impl GenCtx {
fn new() -> Self {
Self {
lets: HashMap::new(),
loop_vars: Vec::new(),
}
}
fn with_let(&self, name: String, expr_swift: String) -> Self {
let mut s = self.clone();
s.lets.insert(name, expr_swift);
s
}
fn with_loop_var(&self, name: String) -> Self {
let mut s = self.clone();
if !name.is_empty() && name != "_" {
s.loop_vars.push(name);
}
s
}
fn resolve_ident(&self, head: &str) -> String {
if self.lets.contains_key(head) {
return head.to_string();
}
for v in self.loop_vars.iter().rev() {
if *v == head {
return head.to_string();
}
}
format!("context.{}", head)
}
}
fn emit_view(nodes: &[Node], view_name: &str, context_type: &str) -> anyhow::Result<String> {
let mut out = String::new();
out.push_str("// Generated by `aurorality swiftgen` — do not edit by hand.\n");
out.push_str("import SwiftUI\n");
let (menubar, dock_bind, body_nodes) = partition_root(nodes);
if dock_bind.is_some() {
out.push_str("#if os(macOS)\nimport AppKit\n#endif\n");
}
out.push('\n');
if dock_bind.is_some() {
out.push_str(
r#"private enum AurorDockBadge {
static func apply(count: Int) {
#if os(macOS)
NSApplication.shared.dockTile.badgeLabel = count > 0 ? "\(count)" : nil
#endif
}
}
"#,
);
}
out.push_str(&format!(
"struct {}: View {{\n let context: {}\n let eventSink: (String) -> Void\n\n",
view_name, context_type
));
out.push_str(" var body: some View {\n");
let cx = GenCtx::new();
let mut body = emit_nodes_block(&body_nodes, &cx, 8)?;
if let Some(bind) = &dock_bind {
let expr = field_accessor(bind, &cx)?;
body = format!(
"{}Group {{\n{}\n{}}}\n{}.onAppear {{ AurorDockBadge.apply(count: Int({})) }}\n{}.onChange(of: {}) {{ _, v in AurorDockBadge.apply(count: Int(v)) }}",
indent(8),
body,
indent(8),
indent(8),
expr,
indent(8),
expr
);
}
out.push_str(&body);
out.push_str(swiftgen_style::root_frame());
out.push_str("\n }\n}\n");
if let Some(mb) = menubar {
out.push_str(&emit_commands_struct(&mb, view_name, context_type, &cx)?);
}
Ok(out)
}
fn partition_root(nodes: &[Node]) -> (Option<Element>, Option<String>, Vec<Node>) {
let mut menubar: Option<Element> = None;
let mut dock_bind: Option<String> = None;
let mut rest: Vec<Node> = Vec::new();
for n in nodes {
match n {
Node::Element(el) if el.tag.eq_ignore_ascii_case("menubar") => {
if menubar.is_none() {
menubar = Some(el.clone());
} else {
rest.push(n.clone());
}
}
Node::Element(el) if el.tag.eq_ignore_ascii_case("dockbadge") => {
if dock_bind.is_none() {
dock_bind = binding_value(el, "bind");
}
}
Node::Element(el) if el.tag.eq_ignore_ascii_case("notification") => {
}
_ => rest.push(n.clone()),
}
}
(menubar, dock_bind, rest)
}
fn emit_commands_struct(
menubar: &Element,
view_name: &str,
context_type: &str,
cx: &GenCtx,
) -> anyhow::Result<String> {
let cmds_name = format!("{view_name}Commands");
let mut inner = String::new();
for child in &menubar.children {
inner.push_str(&emit_menu_top(child, cx, 8)?);
}
Ok(format!(
r#"struct {cmds_name}: Commands {{
let context: {ctx}
let eventSink: (String) -> Void
var body: some Commands {{
{inner}
}}
}}
"#,
cmds_name = cmds_name,
ctx = context_type,
inner = inner
))
}
fn emit_menu_top(n: &Node, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
match n {
Node::Element(el) if el.tag.eq_ignore_ascii_case("menu") => {
let title = binding_value(el, "title").unwrap_or_else(|| "Menu".to_string());
let body = emit_menu_items(&el.children, cx, ind + 4)?;
Ok(format!(
"{}CommandMenu(\"{}\") {{\n{}\n{}}}\n",
indent(ind),
escape_swift_string(&title),
body,
indent(ind)
))
}
_ => Ok(String::new()),
}
}
fn emit_menu_items(nodes: &[Node], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let mut lines = Vec::new();
for n in nodes {
match n {
Node::Element(el) if el.tag.eq_ignore_ascii_case("menuitem") => {
lines.push(emit_menu_item(el, cx, ind)?);
}
Node::Element(el) if el.tag.eq_ignore_ascii_case("menuseparator") => {
lines.push(format!("{}Divider()", indent(ind)));
}
Node::Element(el) if el.tag.eq_ignore_ascii_case("menu") => {
lines.push(emit_menu_top(n, cx, ind)?);
}
_ => {}
}
}
Ok(lines.join("\n"))
}
fn emit_menu_item(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let click = el
.event_handlers
.iter()
.find(|h| h.event == "click")
.map(|h| h.handler.trim_matches('"').to_string())
.unwrap_or_default();
let title = binding_value(el, "title")
.or_else(|| collect_inline_text(&el.children, cx).ok())
.unwrap_or_default();
let title = title.trim().to_string();
let event_expr = event_handler_expr(&click, cx)?;
let shortcut = binding_value(el, "shortcut");
let btn = format!(
"{}Button({}) {{\n{}eventSink({})\n{}}}",
indent(ind),
swift_string_literal(&title),
indent(ind + 4),
event_expr,
indent(ind)
);
if let Some(sh) = shortcut {
if let Some(chain) = keyboard_shortcut_chain(&sh) {
return Ok(format!("{}{}", btn, chain));
}
}
Ok(btn)
}
fn swift_string_literal(s: &str) -> String {
format!("\"{}\"", escape_swift_string(s))
}
fn keyboard_shortcut_chain(spec: &str) -> Option<String> {
let tokens: Vec<String> = spec
.split('+')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
if tokens.is_empty() {
return None;
}
let mut mods: Vec<&str> = Vec::new();
let mut key_parts: Vec<String> = Vec::new();
for t in tokens {
match t.as_str() {
"cmd" | "command" => mods.push("command"),
"shift" => mods.push("shift"),
"opt" | "option" | "alt" => mods.push("option"),
"ctrl" | "control" => mods.push("control"),
other => key_parts.push(other.to_string()),
}
}
if key_parts.is_empty() {
return None;
}
let key = key_parts.join("+");
let mod_arg = match mods.len() {
0 => ".command".to_string(),
1 => format!(".{}", mods[0]),
_ => format!(
"[{}]",
mods.iter()
.map(|m| format!(".{}", m))
.collect::<Vec<_>>()
.join(", ")
),
};
let shortcut = if key == "," {
format!(
"\n .keyboardShortcut(\",\", modifiers: {mod_arg})",
mod_arg = mod_arg
)
} else {
format!(
"\n .keyboardShortcut(\"{}\", modifiers: {mod_arg})",
escape_swift_string(&key),
mod_arg = mod_arg
)
};
Some(shortcut)
}
fn indent(n: usize) -> String {
" ".repeat(n)
}
fn emit_nodes_block(nodes: &[Node], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let mut cx = cx.clone();
for n in nodes {
if let Node::LetDecl(d) = n {
let ex = translate_expr(&d.expr, &cx)?;
cx = cx.with_let(d.name.clone(), ex);
}
}
let mut lines = Vec::new();
for n in nodes {
if matches!(n, Node::LetDecl(_)) {
continue;
}
lines.push(emit_node(n, &cx, ind)?);
}
if lines.is_empty() {
return Ok(format!("{}EmptyView()", indent(ind)));
}
if lines.len() == 1 {
return Ok(lines[0].clone());
}
Ok(lines.join("\n"))
}
fn emit_node(n: &Node, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
match n {
Node::LetDecl(_) => Ok(format!("{}EmptyView()", indent(ind))),
Node::Include(_) => Err(anyhow!(
"`include` is not supported in swiftgen yet; expand or remove includes"
)),
Node::If(b) => emit_if(b, cx, ind),
Node::For(b) => emit_for(b, cx, ind),
Node::Match(b) => emit_match(b, cx, ind),
Node::Text(parts) => Ok(emit_text(parts, cx, ind)?),
Node::RawText(expr) => {
let e = translate_expr(expr.trim(), cx)?;
Ok(format!("{}Text(String(describing: {}))", indent(ind), e))
}
Node::Element(el) => emit_element(el, cx, ind),
Node::Embed(_) => Err(anyhow!(
"`embed` is not supported in swiftgen yet; use IR rendering instead"
)),
Node::RawHtml(_) => Err(anyhow!(
"`raw_html` is not supported in swiftgen yet; use IR rendering instead"
)),
}
}
fn emit_if(b: &IfBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let cond = translate_expr(&b.condition, cx)?;
let then_part = emit_nodes_block(&b.then_children, cx, ind + 4)?;
let else_part = if let Some(els) = &b.else_children {
emit_nodes_block(els, cx, ind + 4)?
} else {
format!("{}EmptyView()", indent(ind + 4))
};
let ind_str = indent(ind);
let mut out = String::new();
out.push_str(&format!("{}if {} {{\n", ind_str, cond));
out.push_str(&then_part);
out.push('\n');
out.push_str(&ind_str);
out.push_str("} else {\n");
out.push_str(&else_part);
out.push('\n');
out.push_str(&ind_str);
out.push_str("}\n");
Ok(out)
}
fn emit_for(b: &ForBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let iter_expr = translate_expr(&b.iterator, cx)?;
let pattern = b.pattern.trim();
let inner_cx = if pattern.is_empty() || pattern == "_" {
cx.clone()
} else {
cx.with_loop_var(pattern.to_string())
};
let inner = emit_nodes_block(&b.body, &inner_cx, ind + 8)?;
if pattern.is_empty() || pattern == "_" {
return Ok(format!(
"{}ForEach(Array({}.enumerated()), id: \\.offset) {{ _, __row in\n{}\n{}}}",
indent(ind),
iter_expr,
inner,
indent(ind + 4)
));
}
Ok(format!(
"{}ForEach(Array({}.enumerated()), id: \\.offset) {{ _, {} in\n{}\n{}}}",
indent(ind),
iter_expr,
pattern,
inner,
indent(ind + 4)
))
}
fn emit_match(b: &MatchBlock, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let e = translate_expr(&b.expr, cx)?;
let mut arms = String::new();
let mut has_wild = false;
for arm in &b.arms {
let p = arm.pattern.trim();
if p == "_" {
has_wild = true;
let body = emit_nodes_block(&arm.body, cx, ind + 12)?;
arms.push_str(&format!(
"{}default: {}\n",
indent(ind + 8),
wrap_in_block(&body, ind + 12)
));
continue;
}
let pat_swift = if let Some(st) = p.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
format!("\"{}\"", st.replace('\\', "\\\\").replace('"', "\\\""))
} else {
format!("\"{}\"", p)
};
let body = emit_nodes_block(&arm.body, cx, ind + 12)?;
arms.push_str(&format!(
"{}case {}: {}\n",
indent(ind + 8),
pat_swift,
wrap_in_block(&body, ind + 12)
));
}
if !has_wild {
arms.push_str(&format!("{}default: EmptyView()\n", indent(ind + 8)));
}
Ok(format!(
"{}VStack(spacing: 0) {{\n{}switch String(describing: {}) {{\n{}\n{}}}\n{}}}",
indent(ind),
indent(ind + 4),
e,
arms,
indent(ind + 4),
indent(ind),
))
}
fn wrap_in_block(body: &str, ind: usize) -> String {
if body.trim_start().starts_with("VStack") || body.trim_start().starts_with("Group") {
body.to_string()
} else {
format!("VStack(spacing: 0) {{\n{}\n{}}}", body, indent(ind))
}
}
fn emit_text(parts: &[TextPart], cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
if parts.len() == 1 {
match &parts[0] {
TextPart::Literal(s) => Ok(format!(
"{}Text(\"{}\")",
indent(ind),
escape_swift_string(s)
)),
TextPart::Expr(e) => Ok(format!(
"{}Text(String(describing: {}))",
indent(ind),
translate_expr(e, cx)?
)),
}
} else {
let mut swift_txt = String::new();
for p in parts {
match p {
TextPart::Literal(s) => {
swift_txt.push_str(&escape_for_swift_interpolation_literal(s))
}
TextPart::Expr(e) => {
swift_txt.push_str(&format!("\\({})", translate_expr(e, cx)?));
}
}
}
Ok(format!("{}Text(\"{}\")", indent(ind), swift_txt))
}
}
fn escape_for_swift_interpolation_literal(s: &str) -> String {
s.replace('\\', "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
}
fn escape_swift_string(s: &str) -> String {
escape_for_swift_interpolation_literal(s)
}
fn sanitize_field_key(s: &str) -> String {
s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()
}
fn binding_value(el: &Element, key: &str) -> Option<String> {
el.bindings
.iter()
.find(|b| b.prop == key)
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.filter(|s| !s.is_empty())
}
fn binding_expression_optional(
el: &Element,
key: &str,
cx: &GenCtx,
) -> anyhow::Result<Option<String>> {
let Some(raw) = binding_value(el, key) else {
return Ok(None);
};
let mut inner = raw.trim().to_string();
inner = inner.trim_matches(|c| c == '"' || c == '\'').to_string();
if inner.starts_with('{') && inner.ends_with('}') {
inner = inner[1..inner.len() - 1].trim().to_string();
}
let expr = if let Some(rest) = inner.strip_prefix('!') {
format!("!({})", translate_expr(rest.trim(), cx)?)
} else {
translate_expr(inner.trim(), cx)?
};
Ok(Some(expr))
}
fn first_text_parts(children: &[Node]) -> Option<&[TextPart]> {
for c in children {
if let Node::Text(parts) = c {
return Some(parts.as_slice());
}
}
None
}
fn emit_custom_element(
tag: &str,
el: &Element,
cx: &GenCtx,
ind: usize,
) -> anyhow::Result<Option<String>> {
if tag == "notification" {
let click = el
.event_handlers
.iter()
.find(|h| h.event == "click" || h.event == "notify")
.map(|h| h.handler.trim_matches('"').to_string())
.or_else(|| binding_value(el, "event"))
.unwrap_or_default();
let event_expr = event_handler_expr(&click, cx)?;
return Ok(Some(format!(
"{}EmptyView().hidden()\n{}.onAppear {{ eventSink({}) }}",
indent(ind),
indent(ind),
event_expr
)));
}
if tag == "dockbadge" {
return Ok(Some(format!("{}EmptyView()", indent(ind))));
}
if tag == "avatar" {
let bind =
sanitize_field_key(&binding_value(el, "bind").unwrap_or_else(|| "id".to_string()));
let field = field_accessor(&bind, cx)?;
let size = binding_value(el, "size").unwrap_or_else(|| "36".into());
let sz: f64 = size.parse().unwrap_or(36.0);
let font = (sz * 0.42).max(10.0);
return Ok(Some(format!(
r#"{}Text(String(String(describing: {}).prefix(1)).uppercased())
.font(.system(size: {}, weight: .semibold))
.foregroundStyle(Color.accentColor)
.frame(width: {}, height: {})
.background(Color.accentColor.opacity(0.12))
.clipShape(Circle())"#,
indent(ind),
field,
font,
sz,
sz
)));
}
if tag == "badge" {
let parts = first_text_parts(&el.children)
.ok_or_else(|| anyhow!("<badge> needs inline text (swiftgen limitation)"))?;
let txt = emit_text(parts, cx, ind)?;
return Ok(Some(format!(
"{}\n .font(.caption2.weight(.semibold))\n .padding(.horizontal, 6)\n .padding(.vertical, 2)\n .background(Color.accentColor)\n .foregroundStyle(Color.white)\n .clipShape(Capsule())",
txt.trim_end()
)));
}
if tag == "messagebubble" {
let bind = sanitize_field_key(&binding_value(el, "bind").unwrap_or_else(|| "m".into()));
let b = field_accessor(&bind, cx)?;
return Ok(Some(format!(
r#"{}Group {{
if {b}.isOutgoing {{
HStack {{
Spacer()
VStack(alignment: .leading, spacing: 4) {{
Text(String(describing: {b}.body))
.font(.callout)
.foregroundStyle(Color.white)
Text(String(describing: {b}.metaLine))
.font(.caption)
.foregroundStyle(Color.white.opacity(0.85))
}}
.padding(12)
.background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color.accentColor))
}}
}} else {{
HStack {{
VStack(alignment: .leading, spacing: 4) {{
Text(String(describing: {b}.body))
.font(.callout)
Text(String(describing: {b}.metaLine))
.font(.caption)
.foregroundStyle(Color.secondary)
}}
.padding(12)
.background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color(nsColor: .underPageBackgroundColor)))
Spacer()
}}
}}
}}"#,
indent(ind),
b = b
)));
}
if tag == "typingindicator" {
let raw_label = binding_value(el, "label").unwrap_or_default();
let stripped = raw_label.trim();
let text_content = if stripped.is_empty() {
swift_string_literal("Typing…")
} else if stripped.starts_with('{') && stripped.ends_with('}') {
let inner = stripped[1..stripped.len() - 1].trim();
format!("String(describing: {})", translate_expr(inner, cx)?)
} else {
swift_string_literal(stripped.trim_matches(|c| c == '"' || c == '\''))
};
return Ok(Some(format!(
r#"{}HStack(spacing: 6) {{
ProgressView()
.scaleEffect(0.65)
Text({})
.font(.caption)
.foregroundStyle(Color.secondary)
}}"#,
indent(ind),
text_content
)));
}
Ok(None)
}
fn emit_container_element(
tag: &str,
el: &Element,
cx: &GenCtx,
ind: usize,
) -> anyhow::Result<Option<String>> {
if tag == "navigationsplitview" {
let mut sidebar_nodes: Vec<Node> = Vec::new();
let mut detail_nodes: Vec<Node> = Vec::new();
for child in &el.children {
if let Node::Element(inner) = child {
let child_tag = inner.tag.to_ascii_lowercase();
if child_tag == "sidebar" {
sidebar_nodes.push(child.clone());
continue;
}
if child_tag == "detail" || child_tag == "content" {
detail_nodes.extend(inner.children.clone());
continue;
}
}
detail_nodes.push(child.clone());
}
if sidebar_nodes.is_empty() {
sidebar_nodes.push(Node::Text(vec![TextPart::Literal("Sidebar".to_string())]));
}
if detail_nodes.is_empty() {
detail_nodes.push(Node::Text(vec![TextPart::Literal("Detail".to_string())]));
}
let sidebar = emit_nodes_block(&sidebar_nodes, cx, ind + 4)?;
let detail = emit_nodes_block(&detail_nodes, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}NavigationSplitView {{\n{}\n{}}} detail: {{\n{}\n{}}}{}",
indent(ind),
sidebar,
indent(ind),
detail,
indent(ind),
mods
)));
}
if tag == "navigationstack" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}NavigationStack {{\n{}\n{}}}{}",
indent(ind),
children,
indent(ind),
mods
)));
}
if tag == "list" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
let list_extras = swiftgen_style::list_extras(&el.classes);
return Ok(Some(format!(
"{}List {{\n{}\n{}}}{}{}",
indent(ind),
children,
indent(ind),
list_extras,
mods
)));
}
if tag == "sidebar" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}List {{\n{}\n{}}}\n{}{}.listStyle(.sidebar)",
indent(ind),
children,
indent(ind),
indent(ind),
mods
)));
}
if tag == "item" {
let click = el
.event_handlers
.iter()
.find(|h| h.event == "click")
.map(|h| h.handler.trim_matches('"').to_string())
.unwrap_or_default();
let row_body = emit_nodes_block(&el.children, cx, ind + 8)?;
let layout = swiftgen_style::container_modifiers(&el.classes, false);
if click.is_empty() {
return Ok(Some(format!(
"{}HStack(alignment: .top, spacing: 8) {{\n{}\n{}}}{}",
indent(ind),
row_body,
indent(ind),
layout
)));
}
let event_expr = event_handler_expr(&click, cx)?;
return Ok(Some(format!(
"{}Button {{\n{}eventSink({})\n{}}} label: {{\n{}\n{}}}\n{}.buttonStyle(.plain){}",
indent(ind),
indent(ind + 4),
event_expr,
indent(ind),
row_body,
indent(ind),
indent(ind),
layout
)));
}
if tag == "form" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}Form {{\n{}\n{}}}{}",
indent(ind),
children,
indent(ind),
mods
)));
}
if tag == "group" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}Group {{\n{}\n{}}}{}",
indent(ind),
children,
indent(ind),
mods
)));
}
if tag == "section" {
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let header = el
.bindings
.iter()
.find(|b| b.prop == "header")
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.filter(|s| !s.is_empty());
let mods = swiftgen_style::container_modifiers(&el.classes, false);
if let Some(header_text) = header {
return Ok(Some(format!(
"{}Section {{\n{}\n{}}} header: {{\n{}Text(\"{}\")\n{}}}{}",
indent(ind),
children,
indent(ind),
indent(ind + 4),
escape_swift_string(&header_text),
indent(ind),
mods
)));
}
return Ok(Some(format!(
"{}Section {{\n{}\n{}}}{}",
indent(ind),
children,
indent(ind),
mods
)));
}
if tag == "vstack" || tag == "hstack" || tag == "zstack" {
let spacing = gap_points(&el.classes)
.map(|n| n.to_string())
.unwrap_or_else(|| "0".to_string());
let children = emit_nodes_block(&el.children, cx, ind + 4)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
let ctor = match tag {
"vstack" => format!("VStack(alignment: .leading, spacing: {spacing})"),
"hstack" => format!("HStack(alignment: .top, spacing: {spacing})"),
_ => "ZStack(alignment: .topLeading)".to_string(),
};
return Ok(Some(format!(
"{}{} {{\n{}\n{}}}{}",
indent(ind),
ctor,
children,
indent(ind),
mods
)));
}
Ok(None)
}
fn emit_control_element(
tag: &str,
el: &Element,
cx: &GenCtx,
ind: usize,
) -> anyhow::Result<Option<String>> {
if tag == "menu" {
let title = binding_value(el, "title").unwrap_or_else(|| "Menu".to_string());
let children = emit_nodes_block(&el.children, cx, ind + 8)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}Menu {{\n{}\n{}}} label: {{\n{}Text(\"{}\")\n{}}}{}",
indent(ind),
children,
indent(ind),
indent(ind + 4),
escape_swift_string(&title),
indent(ind),
mods
)));
}
if tag == "toggle" {
let bind = sanitize_field_key(
&el.bindings
.iter()
.find(|b| b.prop == "bind")
.map(|b| b.value.clone())
.unwrap_or_default(),
);
let label = binding_value(el, "label")
.or_else(|| {
collect_inline_text(&el.children, cx)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "Toggle".to_string());
let field_get = field_accessor(&bind, cx)?;
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}Toggle(\"{}\", isOn: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }})){}",
indent(ind),
escape_swift_string(&label),
field_get,
bind,
mods
)));
}
if tag == "button" {
let click = el
.event_handlers
.iter()
.find(|h| h.event == "click")
.map(|h| h.handler.trim_matches('"').to_string())
.unwrap_or_default();
let event_expr = event_handler_expr(&click, cx)?;
let label_body = emit_nodes_block(&el.children, cx, ind + 8)?;
let base = format!(
"{}Button {{\n{}eventSink({})\n{}}} label: {{\n{}\n{}}}",
indent(ind),
indent(ind + 4),
event_expr,
indent(ind),
label_body,
indent(ind)
);
let extras = swiftgen_style::button_extras(&el.classes);
let layout = swiftgen_style::container_modifiers(&el.classes, false);
let disabled = binding_expression_optional(el, "disabled", cx)?
.map(|e| format!("\n .disabled({e})"))
.unwrap_or_default();
return Ok(Some(format!("{base}{extras}{layout}{disabled}")));
}
if tag == "input" || tag == "textfield" {
let bind = sanitize_field_key(
&el.bindings
.iter()
.find(|b| b.prop == "bind")
.map(|b| b.value.clone())
.unwrap_or_default(),
);
let placeholder = el
.bindings
.iter()
.find(|b| b.prop == "placeholder")
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.unwrap_or_default();
let multiline = el.classes.iter().any(|c| c == "multiline");
let field_get = field_accessor(&bind, cx)?;
let ph = format!("\"{}\"", escape_swift_string(&placeholder));
if multiline {
return Ok(Some(format!(
"{}TextEditor(text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))",
indent(ind), field_get, bind
)));
}
let base = format!(
"{}TextField({}, text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))",
indent(ind), ph, field_get, bind
);
let extras = swiftgen_style::text_field_extras(&el.classes);
return Ok(Some(format!("{base}{extras}")));
}
if tag == "securefield" {
let bind = sanitize_field_key(
&el.bindings
.iter()
.find(|b| b.prop == "bind")
.map(|b| b.value.clone())
.unwrap_or_default(),
);
let placeholder = el
.bindings
.iter()
.find(|b| b.prop == "placeholder")
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.unwrap_or_default();
let field_get = field_accessor(&bind, cx)?;
let ph = format!("\"{}\"", escape_swift_string(&placeholder));
return Ok(Some(format!(
"{}SecureField({}, text: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }}))\n{}.textFieldStyle(.roundedBorder)",
indent(ind),
ph,
field_get,
bind,
indent(ind)
)));
}
if tag == "picker" {
let bind = sanitize_field_key(
&el.bindings
.iter()
.find(|b| b.prop == "bind")
.map(|b| b.value.clone())
.unwrap_or_default(),
);
let sel = field_accessor(&bind, cx)?;
let opts = collect_picker_options(el, cx)?;
let mut body = String::new();
for o in &opts {
body.push_str(&format!(
"{}Text(\"{}\").tag(\"{}\")\n",
indent(ind + 4),
escape_swift_string(&o.1),
escape_swift_string(&o.0),
));
}
return Ok(Some(format!(
"{}Picker(\"\", selection: Binding(get: {{ {} }}, set: {{ eventSink(\"bind:{}:\\($0)\") }})) {{\n{}}}\n{}.pickerStyle(.segmented)\n .padding(.bottom, 4)",
indent(ind),
sel,
bind,
body,
indent(ind)
)));
}
Ok(None)
}
fn emit_content_element(
tag: &str,
el: &Element,
cx: &GenCtx,
ind: usize,
) -> anyhow::Result<Option<String>> {
if tag == "label" {
let title = binding_value(el, "title")
.or_else(|| {
collect_inline_text(&el.children, cx)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_default();
let symbol = binding_value(el, "symbol")
.or_else(|| binding_value(el, "icon"))
.unwrap_or_else(|| "circle".to_string());
let mods = swiftgen_style::container_modifiers(&el.classes, false);
return Ok(Some(format!(
"{}Label(\"{}\", systemImage: \"{}\"){}",
indent(ind),
escape_swift_string(&title),
escape_swift_string(&symbol),
mods
)));
}
if tag == "image" {
let system = binding_value(el, "system")
.or_else(|| binding_value(el, "symbol"))
.or_else(|| {
binding_value(el, "name").and_then(|v| {
if v.starts_with("sf-") {
Some(v.trim_start_matches("sf-").replace('-', "."))
} else {
None
}
})
});
let mods = swiftgen_style::container_modifiers(&el.classes, false);
if let Some(sym) = system {
return Ok(Some(format!(
"{}Image(systemName: \"{}\"){}",
indent(ind),
escape_swift_string(&sym),
mods
)));
}
let asset = binding_value(el, "name").unwrap_or_else(|| "photo".to_string());
return Ok(Some(format!(
"{}Image(\"{}\"){}",
indent(ind),
escape_swift_string(&asset),
mods
)));
}
if tag == "span" || tag == "p" {
let parts = first_text_parts(&el.children).ok_or_else(|| {
anyhow!(
"<{}> must contain text (swiftgen span/p limitation)",
el.tag
)
})?;
let inner = emit_text(parts, cx, 0)?;
let trimmed = inner.trim_start();
let mods = swiftgen_style::text_modifiers(&el.classes);
return Ok(Some(format!("{}{}{}", indent(ind), trimmed, mods)));
}
if tag == "spacer" {
return Ok(Some(format!("{}Spacer()", indent(ind))));
}
if tag == "divider" {
return Ok(Some(format!("{}Divider()", indent(ind))));
}
Ok(None)
}
fn emit_element(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let tag = el.tag.to_ascii_lowercase();
if let Some(res) = emit_custom_element(&tag, el, cx, ind)? {
return Ok(res);
}
if let Some(res) = emit_container_element(&tag, el, cx, ind)? {
return Ok(res);
}
if let Some(res) = emit_control_element(&tag, el, cx, ind)? {
return Ok(res);
}
if let Some(res) = emit_content_element(&tag, el, cx, ind)? {
return Ok(res);
}
if tag == "sf" || tag.starts_with("sf-") {
return emit_sf_symbol(el, cx, ind);
}
let axis = if el.classes.iter().any(|c| c == "flex-col") {
"VStack"
} else {
"HStack"
};
let spacing = gap_points(&el.classes);
let scroll = tag == "scroll" || tag == "scrollview" || el.classes.iter().any(|c| c == "scroll");
let inner_ind = if scroll { ind + 8 } else { ind + 4 };
let close_ind = if scroll { ind + 4 } else { ind };
let children = emit_nodes_block(&el.children, cx, inner_ind)?;
let stack_inner = match spacing {
Some(s) if axis == "VStack" => format!(
"{}(alignment: .leading, spacing: {}) {{\n{}\n{}}}",
axis,
s,
children,
indent(close_ind)
),
Some(s) => format!(
"{}(alignment: .top, spacing: {}) {{\n{}\n{}}}",
axis,
s,
children,
indent(close_ind)
),
None if axis == "VStack" => format!(
"{}(alignment: .leading, spacing: 0) {{\n{}\n{}}}",
axis,
children,
indent(close_ind)
),
None => format!(
"{}(alignment: .top, spacing: 0) {{\n{}\n{}}}",
axis,
children,
indent(close_ind)
),
};
let mods = swiftgen_style::container_modifiers(&el.classes, scroll);
if scroll {
Ok(format!(
"{}ScrollView {{\n{}\n{}}}{}",
indent(ind),
stack_inner,
indent(ind),
mods
))
} else {
Ok(format!("{}{}{}", indent(ind), stack_inner, mods))
}
}
fn emit_sf_symbol(el: &Element, cx: &GenCtx, ind: usize) -> anyhow::Result<String> {
let from_binding = el
.bindings
.iter()
.find(|b| b.prop == "name" || b.prop == "symbol")
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.filter(|s| !s.is_empty());
let from_tag = el
.tag
.strip_prefix("sf-")
.map(|rest| rest.replace('-', "."));
let from_text = collect_inline_text(&el.children, cx)?
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string();
let symbol = from_binding
.or(from_tag)
.or({
if from_text.is_empty() {
None
} else {
Some(from_text)
}
})
.unwrap_or_else(|| "questionmark.circle".to_string());
let mods = swiftgen_style::container_modifiers(&el.classes, false);
Ok(format!(
"{}Image(systemName: \"{}\"){}",
indent(ind),
escape_swift_string(&symbol),
mods
))
}
fn event_handler_expr(handler: &str, cx: &GenCtx) -> anyhow::Result<String> {
if handler.is_empty() {
return Ok("\"\"".to_string());
}
let mut out = String::new();
let mut rest = handler;
while let Some(start) = rest.find('{') {
let (literal, after_start) = rest.split_at(start);
out.push_str(&escape_for_swift_interpolation_literal(literal));
let after_brace = &after_start[1..];
let Some(end) = after_brace.find('}') else {
out.push_str(&escape_for_swift_interpolation_literal(after_start));
rest = "";
break;
};
let expr = &after_brace[..end];
out.push_str("\\(");
out.push_str(&translate_expr(expr, cx)?);
out.push(')');
rest = &after_brace[end + 1..];
}
out.push_str(&escape_for_swift_interpolation_literal(rest));
Ok(format!("\"{}\"", out))
}
struct PickerOpt(String, String);
fn collect_picker_options(el: &Element, cx: &GenCtx) -> anyhow::Result<Vec<PickerOpt>> {
let mut out = Vec::new();
for ch in &el.children {
if let Node::Element(inner) = ch {
if inner.tag == "span" || inner.tag == "button" {
let label = collect_inline_text(&inner.children, cx)?;
let value = inner
.bindings
.iter()
.find(|b| b.prop == "value")
.map(|b| {
b.value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string()
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slug_label(&label));
out.push(PickerOpt(value, label));
}
}
}
Ok(out)
}
fn slug_label(label: &str) -> String {
label
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn field_accessor(bind: &str, cx: &GenCtx) -> anyhow::Result<String> {
let bind = bind.trim();
if bind.contains('.') || bind.contains(' ') {
return translate_expr(bind, cx);
}
if let Some(ex) = cx.lets.get(bind) {
return Ok(format!("({})", ex));
}
Ok(cx.resolve_ident(bind))
}
fn collect_inline_text(children: &[Node], cx: &GenCtx) -> anyhow::Result<String> {
for c in children {
match c {
Node::Text(parts) => {
let mut s = String::new();
for p in parts {
match p {
TextPart::Literal(t) => s.push_str(t),
TextPart::Expr(e) => s.push_str(&translate_expr(e, cx)?),
}
}
if !s.is_empty() {
return Ok(s);
}
}
Node::Element(inner) => {
let s = collect_inline_text(&inner.children, cx)?;
if !s.is_empty() {
return Ok(s);
}
}
_ => {}
}
}
Ok(String::new())
}
fn gap_points(classes: &[String]) -> Option<u32> {
for c in classes {
if let Some(r) = c.strip_prefix("gap-") {
if let Ok(n) = r.parse::<u32>() {
return Some(n * 4);
}
}
}
None
}
fn translate_expr(expr: &str, cx: &GenCtx) -> anyhow::Result<String> {
let e = expr.trim();
if e.is_empty() {
return Ok("false".to_string());
}
rewrite_expr_tokens(e, cx)
}
fn rewrite_expr_tokens(expr: &str, cx: &GenCtx) -> anyhow::Result<String> {
let chars: Vec<char> = expr.chars().collect();
let mut i = 0;
let mut out = String::new();
while i < chars.len() {
if chars[i] == '"' || chars[i] == '\'' {
let quote = chars[i];
out.push(quote);
i += 1;
while i < chars.len() && chars[i] != quote {
out.push(chars[i]);
i += 1;
}
if i < chars.len() {
out.push(chars[i]);
i += 1;
}
continue;
}
if chars[i].is_ascii_alphabetic() || chars[i] == '_' {
let start = i;
while i < chars.len()
&& (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '.')
{
i += 1;
}
let word = chars[start..i].iter().collect::<String>();
if word == "true" || word == "false" || word == "null" {
out.push_str(&word);
continue;
}
if word.contains('.') {
let parts: Vec<&str> = word.split('.').collect();
let head = parts[0];
let rest: Vec<&str> = parts[1..].to_vec();
let root = resolve_chain_head(head, cx)?;
let tail = rest.join(".");
if tail.is_empty() {
out.push_str(&root);
} else {
out.push_str(&format!("{}.{}", root, tail));
}
continue;
}
out.push_str(&resolve_chain_head(&word, cx)?);
continue;
}
if chars[i].is_ascii_digit()
|| (chars[i] == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit())
{
let start = i;
if chars[i] == '-' {
i += 1;
}
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
if i < chars.len() && chars[i] == '.' {
i += 1;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
}
let w = chars[start..i].iter().collect::<String>();
out.push_str(&w);
continue;
}
out.push(chars[i]);
i += 1;
}
Ok(out)
}
fn resolve_chain_head(head: &str, cx: &GenCtx) -> anyhow::Result<String> {
if let Some(l) = cx.lets.get(head) {
return Ok(format!("({})", l));
}
if cx.loop_vars.iter().any(|v| v == head) {
return Ok(head.to_string());
}
Ok(cx.resolve_ident(head))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn swiftgen_stack_and_text() {
let tpl = "div flex flex-col gap-2 p-2\n span \"Hello\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("VStack"));
assert!(s.contains("Hello"));
}
#[test]
fn swiftgen_button_click() {
let tpl = "button @click=\"go\"\n \"OK\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("eventSink(\"go\")"));
}
#[test]
fn swiftgen_dynamic_button_event_and_list() {
let tpl = "navigationsplitview\n sidebar\n section header=\"T\"\n for conv in {conversations}\n button button-plain @click=\"select:{conv.id}\"\n span \"{conv.title}\"\n detail\n span \"x\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("ForEach"));
assert!(s.contains("eventSink(\"select:\\(conv.id)\")"));
assert!(s.contains(".buttonStyle(.plain)"));
}
#[test]
fn swiftgen_sidebar_item_sf() {
let tpl = "for row in rows\n sidebar\n item @click=\"select:{row.id}\"\n sf-plus\n span \"Row\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains(".listStyle(.sidebar)"));
assert!(s.contains("eventSink(\"select:\\(row.id)\")"));
assert!(s.contains("Image(systemName: \"plus\")"));
}
#[test]
fn swiftgen_form_section_and_divider() {
let tpl = "form\n section header=\"General\"\n span \"A\"\n divider\n section\n textfield bind=name placeholder=\"Name\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("Form {"));
assert!(s.contains("Section {"));
assert!(s.contains("header: {"));
assert!(s.contains("Divider()"));
assert!(s.contains("TextField"));
}
#[test]
fn swiftgen_menubar_emits_commands_struct() {
let tpl = r#"menubar
menu title="App"
menuitem @click="openSettings" shortcut="cmd+,"
"Settings"
menuseparator
menuitem @click="refresh"
"Refresh"
navigationsplitview
sidebar
span "Hi"
detail
span "There"
"#;
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "MainView", "MainCtx").unwrap();
assert!(s.contains("struct MainViewCommands: Commands"));
assert!(s.contains("CommandMenu(\"App\")"));
assert!(s.contains("keyboardShortcut(\",\", modifiers: .command)"));
assert!(s.contains("NavigationSplitView"));
}
#[test]
fn swiftgen_navigation_and_misc_components() {
let tpl = r#"navigationstack
navigationsplitview
sidebar
list
item @click="select"
label title="Inbox" symbol="tray"
detail
vstack
menu title="More"
button @click="x"
"Action"
toggle bind=isOn label="Enabled"
image system="paperplane"
"#;
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("NavigationStack"));
assert!(s.contains("NavigationSplitView"));
assert!(s.contains("Menu {"));
assert!(s.contains("Toggle("));
assert!(s.contains("Label(\"Inbox\", systemImage: \"tray\")"));
assert!(s.contains("Image(systemName: \"paperplane\")"));
}
#[test]
fn swiftgen_if_for() {
let tpl = "if show\n span \"y\"\nfor x in items\n span \"z\"";
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("if context.show"));
assert!(s.contains("ForEach"));
}
#[test]
fn swiftgen_input_picker() {
let tpl = r#"input bind=draft placeholder="Hi"
picker bind=mode
span "A"
"#;
let nodes = crepuscularity_core::parser::parse_template(tpl).unwrap();
let s = emit_view(&nodes, "TView", "TContext").unwrap();
assert!(s.contains("TextField"));
assert!(s.contains("bind:draft"));
assert!(s.contains("Picker"));
}
}