use harn_parser::{Node, ShapeField, TypeExpr};
struct Record {
name: String,
fields: Vec<ShapeField>,
}
struct Union {
name: String,
members: Vec<String>,
}
struct Schema {
records: Vec<Record>,
unions: Vec<Union>,
}
pub(crate) fn render(source: &str, header: &str) -> Result<String, String> {
let schema = extract_schema(source)?;
let mut out = String::new();
out.push_str(header);
out.push_str(&preamble(&schema));
for record in &schema.records {
out.push('\n');
out.push_str(&render_record(record, &schema)?);
}
for union in &schema.unions {
out.push('\n');
out.push_str(&render_union(union, &schema)?);
}
Ok(out)
}
fn preamble(schema: &Schema) -> String {
let uses_btreemap = schema
.records
.iter()
.flat_map(|record| &record.fields)
.any(|field| field_uses_dict(&field.type_expr));
let has_union = !schema.unions.is_empty();
let mut out = String::new();
if uses_btreemap {
out.push_str("use std::collections::BTreeMap;\n\n");
}
if has_union {
out.push_str("use serde::{Deserialize, Deserializer, Serialize};\n");
} else {
out.push_str("use serde::{Deserialize, Serialize};\n");
}
out.push_str("use serde_json::Value as JsonValue;\n");
out
}
fn field_uses_dict(type_expr: &TypeExpr) -> bool {
match type_expr {
TypeExpr::DictType(_, _) => true,
TypeExpr::List(inner) => field_uses_dict(inner),
_ => false,
}
}
fn extract_schema(source: &str) -> Result<Schema, String> {
let program = harn_parser::parse_source(source)
.map_err(|error| format!("failed to parse connector schema module: {error:?}"))?;
let mut records = Vec::new();
let mut unions = Vec::new();
for snode in &program {
let Node::TypeDecl {
name, type_expr, ..
} = &snode.node
else {
continue;
};
match type_expr {
TypeExpr::Shape(fields) => records.push(Record {
name: name.clone(),
fields: fields.clone(),
}),
TypeExpr::Union(members) => {
let mut member_names = Vec::with_capacity(members.len());
for member in members {
let TypeExpr::Named(member_name) = member else {
return Err(format!(
"union `{name}` has a non-named member; only named record \
types are supported as union members"
));
};
member_names.push(member_name.clone());
}
unions.push(Union {
name: name.clone(),
members: member_names,
});
}
other => {
return Err(format!(
"type `{name}` is a {} which the connector-schema generator does \
not support (expected a record `{{...}}` or a union `A | B`)",
type_expr_kind(other)
));
}
}
}
Ok(Schema { records, unions })
}
fn type_expr_kind(type_expr: &TypeExpr) -> &'static str {
match type_expr {
TypeExpr::Named(_) => "type alias",
TypeExpr::Union(_) => "union",
TypeExpr::Intersection(_) => "intersection",
TypeExpr::Shape(_) => "record",
TypeExpr::List(_) => "list",
TypeExpr::DictType(_, _) => "dict",
TypeExpr::Applied { .. } => "generic application",
_ => "unsupported type",
}
}
fn render_record(record: &Record, schema: &Schema) -> Result<String, String> {
let mut out = String::new();
if record_is_eq(record, schema) {
out.push_str("#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n");
} else {
out.push_str("#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]\n");
}
out.push_str(&format!("pub struct {} {{\n", record.name));
for field in &record.fields {
out.push_str(&render_field(&record.name, field)?);
}
out.push_str("}\n");
Ok(out)
}
fn record_is_eq(record: &Record, schema: &Schema) -> bool {
record
.fields
.iter()
.all(|field| type_is_eq(&field.type_expr, schema))
}
fn type_is_eq(type_expr: &TypeExpr, schema: &Schema) -> bool {
match type_expr {
TypeExpr::Named(name) => match name.as_str() {
"float" => false,
"string" | "int" | "bool" | "any" => true,
other => schema
.records
.iter()
.find(|record| record.name == other)
.is_some_and(|referenced| record_is_eq(referenced, schema)),
},
TypeExpr::List(inner) => type_is_eq(inner, schema),
TypeExpr::DictType(key, value) => type_is_eq(key, schema) && type_is_eq(value, schema),
_ => false,
}
}
fn render_field(record_name: &str, field: &ShapeField) -> Result<String, String> {
if field.name == "common" && !field.optional {
if let TypeExpr::Named(type_name) = &field.type_expr {
return Ok(format!(
" #[serde(flatten)]\n pub common: {type_name},\n"
));
}
}
let rust_type = rust_type_for(record_name, &field.name, &field.type_expr)?;
let mut out = String::new();
if field.optional {
out.push_str(" #[serde(default, skip_serializing_if = \"Option::is_none\")]\n");
out.push_str(&format!(" pub {}: Option<{rust_type}>,\n", field.name));
} else if is_list(&field.type_expr) || is_dict(&field.type_expr) {
out.push_str(" #[serde(default)]\n");
out.push_str(&format!(" pub {}: {rust_type},\n", field.name));
} else {
out.push_str(&format!(" pub {}: {rust_type},\n", field.name));
}
Ok(out)
}
fn is_list(type_expr: &TypeExpr) -> bool {
matches!(type_expr, TypeExpr::List(_))
}
fn is_dict(type_expr: &TypeExpr) -> bool {
matches!(type_expr, TypeExpr::DictType(_, _))
}
fn rust_type_for(
record_name: &str,
field_name: &str,
type_expr: &TypeExpr,
) -> Result<String, String> {
match type_expr {
TypeExpr::Named(name) => Ok(named_rust_type(name)),
TypeExpr::List(inner) => {
let inner = rust_type_for(record_name, field_name, inner)?;
Ok(format!("Vec<{inner}>"))
}
TypeExpr::DictType(key, value) => {
let key = rust_type_for(record_name, field_name, key)?;
let value = rust_type_for(record_name, field_name, value)?;
Ok(format!("BTreeMap<{key}, {value}>"))
}
other => Err(format!(
"field `{record_name}.{field_name}` uses an unsupported type form ({}); \
the connector-schema generator supports named scalars, `any`, \
`list<T>`, `dict<K, V>`, and named record references only",
type_expr_kind(other)
)),
}
}
fn named_rust_type(name: &str) -> String {
match name {
"string" => "String".to_string(),
"int" => "i64".to_string(),
"float" => "f64".to_string(),
"bool" => "bool".to_string(),
"any" => "JsonValue".to_string(),
other => other.to_string(),
}
}
fn render_union(union: &Union, schema: &Schema) -> Result<String, String> {
let mut variants = Vec::with_capacity(union.members.len());
for member in &union.members {
if !schema.records.iter().any(|record| &record.name == member) {
return Err(format!(
"union `{}` references `{member}`, which is not a record type in the \
same schema module",
union.name
));
}
let variant = variant_name(member);
let event = event_discriminator(member);
variants.push((variant, member.clone(), event));
}
let all_members_eq = union.members.iter().all(|member| {
schema
.records
.iter()
.find(|record| &record.name == member)
.is_some_and(|record| record_is_eq(record, schema))
});
let mut out = String::new();
if all_members_eq {
out.push_str("#[derive(Clone, Debug, PartialEq, Eq, Serialize)]\n");
} else {
out.push_str("#[derive(Clone, Debug, PartialEq, Serialize)]\n");
}
out.push_str("#[serde(untagged)]\n");
out.push_str(&format!("pub enum {} {{\n", union.name));
for (variant, payload, _event) in &variants {
out.push_str(&format!(" {variant}({payload}),\n"));
}
out.push_str("}\n");
out.push_str(&format!(
"\nimpl<'de> Deserialize<'de> for {} {{\n",
union.name
));
out.push_str(" fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n");
out.push_str(" where\n D: Deserializer<'de>,\n {\n");
out.push_str(" let value = JsonValue::deserialize(deserializer)?;\n");
out.push_str(" let kind = value\n");
out.push_str(" .get(\"event\")\n");
out.push_str(" .and_then(JsonValue::as_str)\n");
out.push_str(" .unwrap_or(\"\")\n");
out.push_str(" .to_string();\n");
out.push_str(" let payload = match kind.as_str() {\n");
for (variant, _payload, event) in &variants {
let Some(event) = event else { continue };
out.push_str(&format!(
" {event:?} => {}::{variant}(\n",
union.name
));
out.push_str(
" serde_json::from_value(value).map_err(serde::de::Error::custom)?,\n",
);
out.push_str(" ),\n");
}
let fallback = variants
.iter()
.find(|(_, _, event)| event.is_none())
.ok_or_else(|| {
format!(
"union `{}` has no common-record fallback member (expected one member \
named like `...Common`)",
union.name
)
})?;
out.push_str(&format!(
" _ => {}::{}(\n",
union.name, fallback.0
));
out.push_str(
" serde_json::from_value(value).map_err(serde::de::Error::custom)?,\n",
);
out.push_str(" ),\n");
out.push_str(" };\n");
out.push_str(" Ok(payload)\n");
out.push_str(" }\n}\n");
Ok(out)
}
fn variant_name(record_name: &str) -> String {
let stripped = strip_connector_prefix(record_name);
if let Some(rest) = stripped.strip_suffix("EventCommon") {
if rest.is_empty() {
return "Other".to_string();
}
}
stripped
.strip_suffix("EventPayload")
.unwrap_or(stripped)
.to_string()
}
fn strip_connector_prefix(record_name: &str) -> &str {
for prefix in CONNECTOR_PREFIXES {
if let Some(rest) = record_name.strip_prefix(prefix) {
return rest;
}
}
record_name
}
const CONNECTOR_PREFIXES: &[&str] = &["GitHub", "Slack", "Linear", "Notion"];
fn event_discriminator(record_name: &str) -> Option<String> {
let variant = variant_name(record_name);
if variant == "Other" {
return None;
}
Some(pascal_to_snake(&variant))
}
fn pascal_to_snake(value: &str) -> String {
let mut out = String::new();
for (index, ch) in value.char_indices() {
if ch.is_ascii_uppercase() {
if index != 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}