A human-friendly DSL for ATProto Lexicons
1use mlf_codegen::{CodeGenerator, GeneratorContext, register_generator};
2use mlf_lang::ast::*;
3use std::fmt::Write;
4
5pub struct RustGenerator;
6
7impl RustGenerator {
8 pub const NAME: &'static str = "rust";
9
10 fn to_snake_case(&self, s: &str) -> String {
11 let mut result = String::new();
12 for (i, ch) in s.chars().enumerate() {
13 if ch.is_uppercase() && i > 0 {
14 result.push('_');
15 }
16 result.push(ch.to_lowercase().next().unwrap());
17 }
18 result
19 }
20
21 fn to_pascal_case(&self, s: &str) -> String {
22 let mut chars = s.chars();
23 match chars.next() {
24 None => String::new(),
25 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
26 }
27 }
28
29 fn generate_type(
30 &self,
31 ty: &Type,
32 optional: bool,
33 ctx: &GeneratorContext,
34 ) -> Result<String, String> {
35 let base_type = match ty {
36 Type::Primitive { kind, .. } => match kind {
37 PrimitiveType::Null => "()".to_string(), // Unit type for null
38 PrimitiveType::Boolean => "bool".to_string(),
39 PrimitiveType::Integer => "i64".to_string(),
40 PrimitiveType::String => "String".to_string(),
41 PrimitiveType::Bytes => "Vec<u8>".to_string(),
42 PrimitiveType::Blob => "Vec<u8>".to_string(), // Annotation idea: @rustType("custom::BlobType")
43 },
44 Type::Reference { path, .. } => {
45 let path_str = path.to_string();
46 match path_str.as_str() {
47 // Map standard library types
48 "Datetime" => "String".to_string(), // ISO 8601 string, could use chrono::DateTime
49 "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid"
50 | "RecordKey" | "Uri" | "Language" => "String".to_string(),
51 _ => {
52 // Local reference - convert to PascalCase
53 self.to_pascal_case(&path.segments.last().unwrap().name)
54 }
55 }
56 }
57 Type::Array { inner, .. } => {
58 let inner_type = self.generate_type(inner, false, ctx)?;
59 format!("Vec<{}>", inner_type)
60 }
61 Type::Union { types, .. } => {
62 // Rust doesn't have direct union types, use an enum
63 // For now, generate a simple representation
64 // Annotation idea: @rustEnum to customize enum generation
65 if types.len() == 2
66 && matches!(
67 types[0],
68 Type::Primitive {
69 kind: PrimitiveType::Null,
70 ..
71 }
72 )
73 {
74 // Special case: null | T becomes Option<T>
75 return self.generate_type(&types[1], true, ctx);
76 } else if types.len() == 2
77 && matches!(
78 types[1],
79 Type::Primitive {
80 kind: PrimitiveType::Null,
81 ..
82 }
83 )
84 {
85 return self.generate_type(&types[0], true, ctx);
86 }
87 // Otherwise use serde_json::Value for flexibility
88 "serde_json::Value".to_string()
89 }
90 Type::Object { .. } => {
91 // Inline struct types aren't idiomatic in Rust
92 // We'd need to generate a named type
93 // For now, use serde_json::Value
94 // Annotation idea: @rustInlineStruct to force inline generation
95 "serde_json::Value".to_string()
96 }
97 Type::Parenthesized { inner, .. } => {
98 return self.generate_type(inner, optional, ctx);
99 }
100 Type::Constrained { base, .. } => {
101 return self.generate_type(base, optional, ctx);
102 }
103 Type::Unknown { .. } => "serde_json::Value".to_string(),
104 };
105
106 if optional {
107 Ok(format!("Option<{}>", base_type))
108 } else {
109 Ok(base_type)
110 }
111 }
112
113 fn generate_doc_comment(&self, docs: &[DocComment]) -> String {
114 if docs.is_empty() {
115 return String::new();
116 }
117
118 let mut result = String::new();
119 for doc in docs {
120 result.push_str("/// ");
121 result.push_str(&doc.text);
122 result.push('\n');
123 }
124 result
125 }
126}
127
128impl CodeGenerator for RustGenerator {
129 fn name(&self) -> &'static str {
130 Self::NAME
131 }
132
133 fn description(&self) -> &'static str {
134 "Generate Rust structs and client code"
135 }
136
137 fn file_extension(&self) -> &'static str {
138 ".rs"
139 }
140
141 fn generate(&self, ctx: &GeneratorContext) -> Result<String, String> {
142 let mut output = String::new();
143
144 // Header comment
145 writeln!(output, "// Generated from {}", ctx.namespace).unwrap();
146 writeln!(output, "// Do not edit manually").unwrap();
147 writeln!(output).unwrap();
148
149 // Common imports
150 writeln!(output, "use serde::{{Deserialize, Serialize}};\n").unwrap();
151
152 // Generate code for each item
153 for item in &ctx.lexicon.items {
154 match item {
155 Item::Record(record) => {
156 output.push_str(&self.generate_doc_comment(&record.docs));
157 writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap();
158 writeln!(
159 output,
160 "pub struct {} {{",
161 self.to_pascal_case(&record.name.name)
162 )
163 .unwrap();
164
165 for field in &record.fields {
166 if !field.docs.is_empty() {
167 writeln!(output, " /// {}", field.docs[0].text).unwrap();
168 }
169
170 // Use serde rename for camelCase fields
171 if field.name.name != self.to_snake_case(&field.name.name) {
172 writeln!(output, " #[serde(rename = \"{}\")]", field.name.name)
173 .unwrap();
174 }
175
176 // Skip serializing None values for optional fields
177 if field.optional {
178 writeln!(
179 output,
180 " #[serde(skip_serializing_if = \"Option::is_none\")]"
181 )
182 .unwrap();
183 }
184
185 let field_type = self.generate_type(&field.ty, field.optional, ctx)?;
186 writeln!(
187 output,
188 " pub {}: {},",
189 self.to_snake_case(&field.name.name),
190 field_type
191 )
192 .unwrap();
193 }
194
195 writeln!(output, "}}\n").unwrap();
196 }
197 Item::DefType(def) => {
198 output.push_str(&self.generate_doc_comment(&def.docs));
199
200 match &def.ty {
201 Type::Object { fields, .. } => {
202 // Generate a struct for object types
203 writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]")
204 .unwrap();
205 writeln!(
206 output,
207 "pub struct {} {{",
208 self.to_pascal_case(&def.name.name)
209 )
210 .unwrap();
211
212 for field in fields {
213 if !field.docs.is_empty() {
214 writeln!(output, " /// {}", field.docs[0].text).unwrap();
215 }
216
217 if field.name.name != self.to_snake_case(&field.name.name) {
218 writeln!(
219 output,
220 " #[serde(rename = \"{}\")]",
221 field.name.name
222 )
223 .unwrap();
224 }
225
226 if field.optional {
227 writeln!(
228 output,
229 " #[serde(skip_serializing_if = \"Option::is_none\")]"
230 )
231 .unwrap();
232 }
233
234 let field_type =
235 self.generate_type(&field.ty, field.optional, ctx)?;
236 writeln!(
237 output,
238 " pub {}: {},",
239 self.to_snake_case(&field.name.name),
240 field_type
241 )
242 .unwrap();
243 }
244
245 writeln!(output, "}}\n").unwrap();
246 }
247 _ => {
248 // Type alias
249 writeln!(
250 output,
251 "pub type {} = {};\n",
252 self.to_pascal_case(&def.name.name),
253 self.generate_type(&def.ty, false, ctx)?
254 )
255 .unwrap();
256 }
257 }
258 }
259 Item::InlineType(inline) => {
260 output.push_str(&self.generate_doc_comment(&inline.docs));
261 writeln!(
262 output,
263 "pub type {} = {};\n",
264 self.to_pascal_case(&inline.name.name),
265 self.generate_type(&inline.ty, false, ctx)?
266 )
267 .unwrap();
268 }
269 Item::Token(token) => {
270 output.push_str(&self.generate_doc_comment(&token.docs));
271 writeln!(
272 output,
273 "pub const {}: &str = \"{}\";\n",
274 token.name.name.to_uppercase(),
275 token.name.name
276 )
277 .unwrap();
278 }
279 Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => {
280 // TODO: Generate client methods
281 }
282 Item::Use(_) | Item::SelfItem(_) => {
283 // Skip `use` statements and the `self { }` item — both
284 // are lexicon-level metadata, not types to emit.
285 }
286 }
287 }
288
289 Ok(output)
290 }
291}
292
293// Register the Rust generator
294pub static RUST_GENERATOR: RustGenerator = RustGenerator;
295register_generator!(RUST_GENERATOR);