use std::fmt::Write;
#[derive(Debug, Clone)]
pub enum Expr {
String(String),
Int(i64),
Ident(String),
DotMember(String),
Array(Vec<Expr>),
Call { head: String, args: Vec<(Option<String>, Expr)> },
}
impl Expr {
pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
pub fn i(v: i64) -> Self { Self::Int(v) }
pub fn ident(v: impl Into<String>) -> Self { Self::Ident(v.into()) }
pub fn dot(member: impl Into<String>) -> Self { Self::DotMember(member.into()) }
pub fn arr(items: impl IntoIterator<Item = Expr>) -> Self {
Self::Array(items.into_iter().collect())
}
pub fn call(head: impl Into<String>, args: Vec<(Option<String>, Expr)>) -> Self {
Self::Call { head: head.into(), args }
}
pub fn pos(e: Expr) -> (Option<String>, Expr) { (None, e) }
pub fn lbl(label: &str, e: Expr) -> (Option<String>, Expr) {
(Some(label.to_string()), e)
}
fn render(&self, indent: usize) -> String {
match self {
Self::String(s) => render_swift_string(s),
Self::Int(n) => n.to_string(),
Self::Ident(s) => s.clone(),
Self::DotMember(s) => {
let mut out = String::from(".");
out.push_str(s);
out
}
Self::Array(items) if items.is_empty() => "[]".to_string(),
Self::Array(items) => {
let inline: Vec<String> = items.iter().map(|v| v.render(indent + 1)).collect();
let joined = inline.join(", ");
let oneline = {
let mut a = String::from("[");
a.push_str(&joined);
a.push(']');
a
};
if oneline.len() <= 80 && !oneline.contains('\n') {
oneline
} else {
let pad_outer = " ".repeat(indent);
let pad_inner = " ".repeat(indent + 1);
let mut out = String::from("[\n");
for (i, v) in items.iter().enumerate() {
let _ = write!(out, "{pad_inner}{}", v.render(indent + 1));
if i + 1 < items.len() { out.push(','); }
out.push('\n');
}
let _ = write!(out, "{pad_outer}]");
out
}
}
Self::Call { head, args } if args.is_empty() => {
let mut out = String::from(head);
out.push_str("()");
out
}
Self::Call { head, args } => {
let inline_parts: Vec<String> = args.iter().map(|(lbl, v)| {
let mut p = String::new();
if let Some(l) = lbl {
p.push_str(l);
p.push_str(": ");
}
p.push_str(&v.render(indent + 1));
p
}).collect();
let oneline = {
let mut a = String::from(head);
a.push('(');
a.push_str(&inline_parts.join(", "));
a.push(')');
a
};
if oneline.len() <= 80 && !oneline.contains('\n') {
oneline
} else {
let pad_outer = " ".repeat(indent);
let pad_inner = " ".repeat(indent + 1);
let mut out = String::from(head);
out.push_str("(\n");
for (i, part) in inline_parts.iter().enumerate() {
let _ = write!(out, "{pad_inner}{part}");
if i + 1 < inline_parts.len() { out.push(','); }
out.push('\n');
}
let _ = write!(out, "{pad_outer})");
out
}
}
}
}
}
fn render_swift_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str(r"\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
#[derive(Debug, Clone)]
pub enum Stmt {
Let { name: String, value: Expr },
Blank,
Comment(String),
}
#[derive(Debug, Clone, Default)]
pub struct File {
pub tools_version: Option<String>,
pub imports: Vec<String>,
pub stmts: Vec<Stmt>,
}
impl File {
pub fn new() -> Self { Self::default() }
pub fn tools(&mut self, v: impl Into<String>) -> &mut Self {
self.tools_version = Some(v.into()); self
}
pub fn import(&mut self, m: impl Into<String>) -> &mut Self {
self.imports.push(m.into()); self
}
pub fn push(&mut self, s: Stmt) -> &mut Self { self.stmts.push(s); self }
pub fn render(&self) -> String {
let mut out = String::new();
if let Some(v) = &self.tools_version {
let _ = writeln!(out, "// swift-tools-version:{v}");
}
for m in &self.imports {
let _ = writeln!(out, "import {m}");
}
if (self.tools_version.is_some() || !self.imports.is_empty()) && !self.stmts.is_empty() {
out.push('\n');
}
for s in &self.stmts {
match s {
Stmt::Let { name, value } => {
let _ = writeln!(out, "let {name} = {}", value.render(0));
}
Stmt::Blank => out.push('\n'),
Stmt::Comment(c) => {
let _ = writeln!(out, "// {c}");
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn string_escapes_quote_and_backslash() {
assert_eq!(render_swift_string(r#"a"b\c"#), r#""a\"b\\c""#);
}
#[test]
fn dot_member_renders_with_leading_dot() {
assert_eq!(Expr::dot("v13").render(0), ".v13");
}
#[test]
fn leading_dot_call_keeps_dot_in_head() {
let e = Expr::call(".macOS", vec![Expr::pos(Expr::dot("v13"))]);
assert_eq!(e.render(0), ".macOS(.v13)");
}
#[test]
fn short_call_renders_inline() {
let e = Expr::call("Foo", vec![Expr::lbl("name", Expr::s("x"))]);
assert_eq!(e.render(0), "Foo(name: \"x\")");
}
#[test]
fn long_call_breaks_to_multiline() {
let e = Expr::call("Package", vec![
Expr::lbl("name", Expr::s("very-long-name-that-pushes-us-past-the-eighty-char-budget")),
Expr::lbl("platforms", Expr::arr([Expr::call(".macOS", vec![Expr::pos(Expr::dot("v13"))])])),
]);
let out = e.render(0);
assert!(out.starts_with("Package(\n"), "should break to multi-line");
assert!(out.ends_with(")"));
assert!(out.contains(" name:"), "each arg should be 4-space indented");
}
#[test]
fn file_emits_tools_version_and_import() {
let mut f = File::new();
f.tools("5.9").import("PackageDescription");
f.push(Stmt::Let {
name: "package".into(),
value: Expr::call("Package", vec![Expr::lbl("name", Expr::s("demo"))]),
});
let out = f.render();
assert!(out.starts_with("// swift-tools-version:5.9\n"));
assert!(out.contains("\nimport PackageDescription\n"));
assert!(out.contains("\nlet package = Package(name: \"demo\")\n"));
}
}