diff --git a/Cargo.lock b/Cargo.lock index 57e6b13..73181aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,6 +540,15 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -683,10 +692,42 @@ dependencies = [ name = "mlf-codegen" version = "0.1.0" dependencies = [ + "inventory", + "mlf-codegen-go", + "mlf-codegen-rust", + "mlf-codegen-typescript", "mlf-lang", "serde_json", ] +[[package]] +name = "mlf-codegen-go" +version = "0.1.0" +dependencies = [ + "mlf-codegen", + "mlf-lang", +] + +[[package]] +name = "mlf-codegen-java" +version = "0.1.0" + +[[package]] +name = "mlf-codegen-rust" +version = "0.1.0" +dependencies = [ + "mlf-codegen", + "mlf-lang", +] + +[[package]] +name = "mlf-codegen-typescript" +version = "0.1.0" +dependencies = [ + "mlf-codegen", + "mlf-lang", +] + [[package]] name = "mlf-diagnostics" version = "0.1.0" @@ -703,6 +744,18 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "mlf-playground-wasm" +version = "0.1.0" +dependencies = [ + "mlf-codegen", + "mlf-codegen-go", + "mlf-codegen-rust", + "mlf-codegen-typescript", + "mlf-wasm", + "wasm-bindgen", +] + [[package]] name = "mlf-validation" version = "0.1.0" @@ -728,6 +781,7 @@ dependencies = [ "serde_json", "wasm-bindgen", "wasm-bindgen-test", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0cf032c..22846f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [workspace] resolver = "3" -members = [ +members = ["codegen-plugins/mlf-codegen-go", "codegen-plugins/mlf-codegen-java", "codegen-plugins/mlf-codegen-rust","codegen-plugins/mlf-codegen-typescript", "mlf-cli", "mlf-codegen", "mlf-diagnostics", "mlf-lang", "mlf-validation", "mlf-wasm", - "tree-sitter-mlf" -] + "tree-sitter-mlf", + "website/mlf-playground-wasm"] default-members = [ "mlf-cli" diff --git a/codegen-plugins/mlf-codegen-go/Cargo.toml b/codegen-plugins/mlf-codegen-go/Cargo.toml new file mode 100644 index 0000000..8d08760 --- /dev/null +++ b/codegen-plugins/mlf-codegen-go/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mlf-codegen-go" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Go code generator plugin for MLF" + +[dependencies] +mlf-lang = { path = "../../mlf-lang" } +mlf-codegen = { path = "../../mlf-codegen" } diff --git a/codegen-plugins/mlf-codegen-go/src/lib.rs b/codegen-plugins/mlf-codegen-go/src/lib.rs new file mode 100644 index 0000000..d9a8eb8 --- /dev/null +++ b/codegen-plugins/mlf-codegen-go/src/lib.rs @@ -0,0 +1,217 @@ +use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; +use mlf_lang::ast::*; +use std::fmt::Write; + +pub struct GoGenerator; + +impl GoGenerator { + pub const NAME: &'static str = "go"; + + fn to_snake_case(&self, s: &str) -> String { + // Simple camelCase to snake_case conversion + let mut result = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_lowercase().next().unwrap()); + } + result + } + + fn generate_type(&self, ty: &Type, optional: bool, ctx: &GeneratorContext) -> Result { + let base_type = match ty { + Type::Primitive { kind, .. } => match kind { + PrimitiveType::Null => "interface{}", + PrimitiveType::Boolean => "bool", + PrimitiveType::Integer => "int64", + PrimitiveType::String => "string", + PrimitiveType::Bytes => "[]byte", + PrimitiveType::Blob => "[]byte", // Annotation idea: @goType("custom.BlobType") + }.to_string(), + Type::Reference { path, .. } => { + let path_str = path.to_string(); + match path_str.as_str() { + // Map standard library types + "Datetime" => "string".to_string(), // ISO 8601 string + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { + "string".to_string() + } + _ => { + // Local reference + path.segments.last().unwrap().name.clone() + } + } + } + Type::Array { inner, .. } => { + let inner_type = self.generate_type(inner, false, ctx)?; + format!("[]{}", inner_type) + } + Type::Union { .. } => { + // Go doesn't have union types, use interface{} + // Annotation idea: @goUnion to generate type switch helpers + "interface{}".to_string() + } + Type::Object { fields, .. } => { + let mut obj = String::from("struct {\n"); + for field in fields { + let field_name = self.capitalize(&field.name.name); + let field_type = self.generate_type(&field.ty, field.optional, ctx)?; + let json_name = field.name.name.clone(); + + if !field.docs.is_empty() { + write!(obj, "\t\t// {}\n", field.docs[0].text).unwrap(); + } + write!(obj, "\t\t{} {} `json:\"{}", field_name, field_type, json_name).unwrap(); + if field.optional { + write!(obj, ",omitempty").unwrap(); + } + writeln!(obj, "\"`").unwrap(); + } + obj.push_str("\t}"); + obj + } + Type::Parenthesized { inner, .. } => { + return self.generate_type(inner, optional, ctx); + } + Type::Constrained { base, .. } => { + return self.generate_type(base, optional, ctx); + } + Type::Unknown { .. } => "interface{}".to_string(), + }; + + // For optional fields, use pointer types + if optional { + Ok(format!("*{}", base_type)) + } else { + Ok(base_type) + } + } + + fn capitalize(&self, s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } + } + + fn generate_doc_comment(&self, docs: &[DocComment]) -> String { + if docs.is_empty() { + return String::new(); + } + + let mut result = String::new(); + for doc in docs { + result.push_str("// "); + result.push_str(&doc.text); + result.push('\n'); + } + result + } +} + +impl CodeGenerator for GoGenerator { + fn name(&self) -> &'static str { + Self::NAME + } + + fn description(&self) -> &'static str { + "Generate Go structs and client code" + } + + fn file_extension(&self) -> &'static str { + ".go" + } + + fn generate(&self, ctx: &GeneratorContext) -> Result { + let mut output = String::new(); + + // Header comment + writeln!(output, "// Generated from {}", ctx.namespace).unwrap(); + writeln!(output, "// Do not edit manually").unwrap(); + writeln!(output).unwrap(); + + // Package name (use last segment of namespace) + let package_name = ctx.namespace.split('.').last().unwrap_or("lexicon"); + writeln!(output, "package {}\n", package_name).unwrap(); + + // Generate code for each item + for item in &ctx.lexicon.items { + match item { + Item::Record(record) => { + output.push_str(&self.generate_doc_comment(&record.docs)); + writeln!(output, "type {} struct {{", self.capitalize(&record.name.name)).unwrap(); + + for field in &record.fields { + let field_name = self.capitalize(&field.name.name); + let field_type = self.generate_type(&field.ty, field.optional, ctx)?; + let json_name = &field.name.name; + + if !field.docs.is_empty() { + writeln!(output, "\t// {}", field.docs[0].text).unwrap(); + } + write!(output, "\t{} {} `json:\"{}\"", field_name, field_type, json_name).unwrap(); + if field.optional { + write!(output, ",omitempty").unwrap(); + } + writeln!(output, "`").unwrap(); + } + + writeln!(output, "}}\n").unwrap(); + } + Item::DefType(def) => { + output.push_str(&self.generate_doc_comment(&def.docs)); + let type_name = self.capitalize(&def.name.name); + + match &def.ty { + Type::Object { .. } => { + // Object types become structs + writeln!( + output, + "type {} {}\n", + type_name, + self.generate_type(&def.ty, false, ctx)? + ).unwrap(); + } + _ => { + // Other types become type aliases + writeln!( + output, + "type {} {}\n", + type_name, + self.generate_type(&def.ty, false, ctx)? + ).unwrap(); + } + } + } + Item::InlineType(inline) => { + output.push_str(&self.generate_doc_comment(&inline.docs)); + writeln!( + output, + "type {} {}\n", + self.capitalize(&inline.name.name), + self.generate_type(&inline.ty, false, ctx)? + ).unwrap(); + } + Item::Token(token) => { + output.push_str(&self.generate_doc_comment(&token.docs)); + let const_name = token.name.name.to_uppercase(); + writeln!(output, "const {} = \"{}\"\n", const_name, token.name.name).unwrap(); + } + Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { + // TODO: Generate client methods + } + Item::Use(_) => { + // Skip use statements + } + } + } + + Ok(output) + } +} + +// Register the Go generator +pub static GO_GENERATOR: GoGenerator = GoGenerator; +register_generator!(GO_GENERATOR); diff --git a/codegen-plugins/mlf-codegen-java/Cargo.toml b/codegen-plugins/mlf-codegen-java/Cargo.toml new file mode 100644 index 0000000..e0f7503 --- /dev/null +++ b/codegen-plugins/mlf-codegen-java/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "mlf-codegen-java" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/codegen-plugins/mlf-codegen-java/src/lib.rs b/codegen-plugins/mlf-codegen-java/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/codegen-plugins/mlf-codegen-java/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/codegen-plugins/mlf-codegen-rust/Cargo.toml b/codegen-plugins/mlf-codegen-rust/Cargo.toml new file mode 100644 index 0000000..e84a9ea --- /dev/null +++ b/codegen-plugins/mlf-codegen-rust/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mlf-codegen-rust" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Rust code generator plugin for MLF" + +[dependencies] +mlf-lang = { path = "../../mlf-lang" } +mlf-codegen = { path = "../../mlf-codegen" } diff --git a/codegen-plugins/mlf-codegen-rust/src/lib.rs b/codegen-plugins/mlf-codegen-rust/src/lib.rs new file mode 100644 index 0000000..b494b42 --- /dev/null +++ b/codegen-plugins/mlf-codegen-rust/src/lib.rs @@ -0,0 +1,231 @@ +use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; +use mlf_lang::ast::*; +use std::fmt::Write; + +pub struct RustGenerator; + +impl RustGenerator { + pub const NAME: &'static str = "rust"; + + fn to_snake_case(&self, s: &str) -> String { + let mut result = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_lowercase().next().unwrap()); + } + result + } + + fn to_pascal_case(&self, s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } + } + + fn generate_type(&self, ty: &Type, optional: bool, ctx: &GeneratorContext) -> Result { + let base_type = match ty { + Type::Primitive { kind, .. } => match kind { + PrimitiveType::Null => "()".to_string(), // Unit type for null + PrimitiveType::Boolean => "bool".to_string(), + PrimitiveType::Integer => "i64".to_string(), + PrimitiveType::String => "String".to_string(), + PrimitiveType::Bytes => "Vec".to_string(), + PrimitiveType::Blob => "Vec".to_string(), // Annotation idea: @rustType("custom::BlobType") + }, + Type::Reference { path, .. } => { + let path_str = path.to_string(); + match path_str.as_str() { + // Map standard library types + "Datetime" => "String".to_string(), // ISO 8601 string, could use chrono::DateTime + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { + "String".to_string() + } + _ => { + // Local reference - convert to PascalCase + self.to_pascal_case(&path.segments.last().unwrap().name) + } + } + } + Type::Array { inner, .. } => { + let inner_type = self.generate_type(inner, false, ctx)?; + format!("Vec<{}>", inner_type) + } + Type::Union { types, .. } => { + // Rust doesn't have direct union types, use an enum + // For now, generate a simple representation + // Annotation idea: @rustEnum to customize enum generation + if types.len() == 2 && matches!(types[0], Type::Primitive { kind: PrimitiveType::Null, .. }) { + // Special case: null | T becomes Option + return self.generate_type(&types[1], true, ctx); + } else if types.len() == 2 && matches!(types[1], Type::Primitive { kind: PrimitiveType::Null, .. }) { + return self.generate_type(&types[0], true, ctx); + } + // Otherwise use serde_json::Value for flexibility + "serde_json::Value".to_string() + } + Type::Object { fields, .. } => { + // Inline struct types aren't idiomatic in Rust + // We'd need to generate a named type + // For now, use serde_json::Value + // Annotation idea: @rustInlineStruct to force inline generation + "serde_json::Value".to_string() + } + Type::Parenthesized { inner, .. } => { + return self.generate_type(inner, optional, ctx); + } + Type::Constrained { base, .. } => { + return self.generate_type(base, optional, ctx); + } + Type::Unknown { .. } => "serde_json::Value".to_string(), + }; + + if optional { + Ok(format!("Option<{}>", base_type)) + } else { + Ok(base_type) + } + } + + fn generate_doc_comment(&self, docs: &[DocComment]) -> String { + if docs.is_empty() { + return String::new(); + } + + let mut result = String::new(); + for doc in docs { + result.push_str("/// "); + result.push_str(&doc.text); + result.push('\n'); + } + result + } +} + +impl CodeGenerator for RustGenerator { + fn name(&self) -> &'static str { + Self::NAME + } + + fn description(&self) -> &'static str { + "Generate Rust structs and client code" + } + + fn file_extension(&self) -> &'static str { + ".rs" + } + + fn generate(&self, ctx: &GeneratorContext) -> Result { + let mut output = String::new(); + + // Header comment + writeln!(output, "// Generated from {}", ctx.namespace).unwrap(); + writeln!(output, "// Do not edit manually").unwrap(); + writeln!(output).unwrap(); + + // Common imports + writeln!(output, "use serde::{{Deserialize, Serialize}};\n").unwrap(); + + // Generate code for each item + for item in &ctx.lexicon.items { + match item { + Item::Record(record) => { + output.push_str(&self.generate_doc_comment(&record.docs)); + writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap(); + writeln!(output, "pub struct {} {{", self.to_pascal_case(&record.name.name)).unwrap(); + + for field in &record.fields { + if !field.docs.is_empty() { + writeln!(output, " /// {}", field.docs[0].text).unwrap(); + } + + // Use serde rename for camelCase fields + if field.name.name != self.to_snake_case(&field.name.name) { + writeln!(output, " #[serde(rename = \"{}\")]", field.name.name).unwrap(); + } + + // Skip serializing None values for optional fields + if field.optional { + writeln!(output, " #[serde(skip_serializing_if = \"Option::is_none\")]").unwrap(); + } + + let field_type = self.generate_type(&field.ty, field.optional, ctx)?; + writeln!(output, " pub {}: {},", self.to_snake_case(&field.name.name), field_type).unwrap(); + } + + writeln!(output, "}}\n").unwrap(); + } + Item::DefType(def) => { + output.push_str(&self.generate_doc_comment(&def.docs)); + + match &def.ty { + Type::Object { fields, .. } => { + // Generate a struct for object types + writeln!(output, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap(); + writeln!(output, "pub struct {} {{", self.to_pascal_case(&def.name.name)).unwrap(); + + for field in fields { + if !field.docs.is_empty() { + writeln!(output, " /// {}", field.docs[0].text).unwrap(); + } + + if field.name.name != self.to_snake_case(&field.name.name) { + writeln!(output, " #[serde(rename = \"{}\")]", field.name.name).unwrap(); + } + + if field.optional { + writeln!(output, " #[serde(skip_serializing_if = \"Option::is_none\")]").unwrap(); + } + + let field_type = self.generate_type(&field.ty, field.optional, ctx)?; + writeln!(output, " pub {}: {},", self.to_snake_case(&field.name.name), field_type).unwrap(); + } + + writeln!(output, "}}\n").unwrap(); + } + _ => { + // Type alias + writeln!( + output, + "pub type {} = {};\n", + self.to_pascal_case(&def.name.name), + self.generate_type(&def.ty, false, ctx)? + ).unwrap(); + } + } + } + Item::InlineType(inline) => { + output.push_str(&self.generate_doc_comment(&inline.docs)); + writeln!( + output, + "pub type {} = {};\n", + self.to_pascal_case(&inline.name.name), + self.generate_type(&inline.ty, false, ctx)? + ).unwrap(); + } + Item::Token(token) => { + output.push_str(&self.generate_doc_comment(&token.docs)); + writeln!(output, "pub const {}: &str = \"{}\";\n", + token.name.name.to_uppercase(), + token.name.name + ).unwrap(); + } + Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { + // TODO: Generate client methods + } + Item::Use(_) => { + // Skip use statements + } + } + } + + Ok(output) + } +} + +// Register the Rust generator +pub static RUST_GENERATOR: RustGenerator = RustGenerator; +register_generator!(RUST_GENERATOR); diff --git a/codegen-plugins/mlf-codegen-typescript/Cargo.toml b/codegen-plugins/mlf-codegen-typescript/Cargo.toml new file mode 100644 index 0000000..e501377 --- /dev/null +++ b/codegen-plugins/mlf-codegen-typescript/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mlf-codegen-typescript" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "TypeScript code generator plugin for MLF" + +[dependencies] +mlf-lang = { path = "../../mlf-lang" } +mlf-codegen = { path = "../../mlf-codegen" } diff --git a/codegen-plugins/mlf-codegen-typescript/src/lib.rs b/codegen-plugins/mlf-codegen-typescript/src/lib.rs new file mode 100644 index 0000000..98bd459 --- /dev/null +++ b/codegen-plugins/mlf-codegen-typescript/src/lib.rs @@ -0,0 +1,177 @@ +use mlf_codegen::{register_generator, CodeGenerator, GeneratorContext}; +use mlf_lang::ast::*; +use std::fmt::Write; + +pub struct TypeScriptGenerator; + +impl TypeScriptGenerator { + pub const NAME: &'static str = "typescript"; + + fn generate_type(&self, ty: &Type, ctx: &GeneratorContext) -> Result { + match ty { + Type::Primitive { kind, .. } => Ok(match kind { + PrimitiveType::Null => "null".to_string(), + PrimitiveType::Boolean => "boolean".to_string(), + PrimitiveType::Integer => "number".to_string(), + PrimitiveType::String => "string".to_string(), + PrimitiveType::Bytes => "Uint8Array".to_string(), + PrimitiveType::Blob => "Blob".to_string(), // Annotation idea: @tsType("CustomBlobType") + }), + Type::Reference { path, .. } => { + // Check if it's from the standard library + let path_str = path.to_string(); + + // Map standard library types to TypeScript types + Ok(match path_str.as_str() { + "Datetime" => "string".to_string(), // ISO 8601 + "Did" | "AtUri" | "Cid" | "AtIdentifier" | "Handle" | "Nsid" | "Tid" | "RecordKey" | "Uri" | "Language" => { + "string".to_string() + } + _ => { + // Local or cross-file reference + path.segments.last().unwrap().name.clone() + } + }) + } + Type::Array { inner, .. } => { + let inner_type = self.generate_type(inner, ctx)?; + Ok(format!("{}[]", inner_type)) + } + Type::Union { types, .. } => { + let type_strings: Result, _> = types + .iter() + .map(|t| self.generate_type(t, ctx)) + .collect(); + Ok(type_strings?.join(" | ")) + } + Type::Object { fields, .. } => { + let mut obj = String::from("{\n"); + for field in fields { + if !field.docs.is_empty() { + obj.push_str(" /** "); + obj.push_str(&field.docs[0].text); + obj.push_str(" */\n"); + } + obj.push_str(" "); + obj.push_str(&field.name.name); + if field.optional { + obj.push('?'); + } + obj.push_str(": "); + obj.push_str(&self.generate_type(&field.ty, ctx)?); + obj.push_str(";\n"); + } + obj.push('}'); + Ok(obj) + } + Type::Parenthesized { inner, .. } => { + let inner_type = self.generate_type(inner, ctx)?; + Ok(format!("({})", inner_type)) + } + Type::Constrained { base, .. } => { + // For constrained types, just use the base type + // Annotations like @min, @max could be added for runtime validation libraries + self.generate_type(base, ctx) + } + Type::Unknown { .. } => Ok("unknown".to_string()), + } + } + + fn generate_doc_comment(&self, docs: &[DocComment]) -> String { + if docs.is_empty() { + return String::new(); + } + + let mut result = String::from("/**\n"); + for doc in docs { + result.push_str(" * "); + result.push_str(&doc.text); + result.push('\n'); + } + result.push_str(" */\n"); + result + } +} + +impl CodeGenerator for TypeScriptGenerator { + fn name(&self) -> &'static str { + Self::NAME + } + + fn description(&self) -> &'static str { + "Generate TypeScript type definitions and client code" + } + + fn file_extension(&self) -> &'static str { + ".ts" + } + + fn generate(&self, ctx: &GeneratorContext) -> Result { + let mut output = String::new(); + + // Header comment + writeln!(output, "/**").unwrap(); + writeln!(output, " * Generated from {}", ctx.namespace).unwrap(); + writeln!(output, " * Do not edit manually").unwrap(); + writeln!(output, " */").unwrap(); + writeln!(output).unwrap(); + + // Generate code for each item + for item in &ctx.lexicon.items { + match item { + Item::Record(record) => { + output.push_str(&self.generate_doc_comment(&record.docs)); + writeln!(output, "export interface {} {{", record.name.name).unwrap(); + + for field in &record.fields { + if !field.docs.is_empty() { + write!(output, " /** {} */\n", field.docs[0].text).unwrap(); + } + write!(output, " {}", field.name.name).unwrap(); + if field.optional { + write!(output, "?").unwrap(); + } + writeln!(output, ": {};", self.generate_type(&field.ty, ctx)?).unwrap(); + } + + writeln!(output, "}}\n").unwrap(); + } + Item::DefType(def) => { + output.push_str(&self.generate_doc_comment(&def.docs)); + writeln!( + output, + "export type {} = {};\n", + def.name.name, + self.generate_type(&def.ty, ctx)? + ).unwrap(); + } + Item::InlineType(inline) => { + output.push_str(&self.generate_doc_comment(&inline.docs)); + writeln!( + output, + "export type {} = {};\n", + inline.name.name, + self.generate_type(&inline.ty, ctx)? + ).unwrap(); + } + Item::Token(token) => { + output.push_str(&self.generate_doc_comment(&token.docs)); + writeln!(output, "export const {} = Symbol('{}');\n", token.name.name, token.name.name).unwrap(); + } + Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) => { + // TODO: Generate client methods for these + // Annotation idea: @clientMethod for custom generation + } + Item::Use(_) => { + // Skip use statements in output + } + } + } + + Ok(output) + } +} + +// Register the TypeScript generator +pub static TYPESCRIPT_GENERATOR: TypeScriptGenerator = TypeScriptGenerator; +register_generator!(TYPESCRIPT_GENERATOR); diff --git a/mlf-codegen/Cargo.toml b/mlf-codegen/Cargo.toml index 63a174c..b14ac6d 100644 --- a/mlf-codegen/Cargo.toml +++ b/mlf-codegen/Cargo.toml @@ -7,3 +7,9 @@ license = "MIT" [dependencies] mlf-lang = { path = "../mlf-lang" } serde_json = { version = "1", features = ["preserve_order"] } +inventory = "0.3" + +[dev-dependencies] +mlf-codegen-typescript = { path = "../codegen-plugins/mlf-codegen-typescript" } +mlf-codegen-go = { path = "../codegen-plugins/mlf-codegen-go" } +mlf-codegen-rust = { path = "../codegen-plugins/mlf-codegen-rust" } diff --git a/mlf-codegen/examples/all_generators.rs b/mlf-codegen/examples/all_generators.rs new file mode 100644 index 0000000..9edbf1e --- /dev/null +++ b/mlf-codegen/examples/all_generators.rs @@ -0,0 +1,26 @@ +// This example loads all codegen plugins and lists them +// +// Run with: cargo run --example all_generators -p mlf-codegen + +use mlf_codegen::plugin; + +// Import all the plugin crates to trigger their registration +extern crate mlf_codegen_typescript; +extern crate mlf_codegen_go; +extern crate mlf_codegen_rust; + +fn main() { + println!("MLF Code Generator Plugins (All Loaded)\n"); + println!("========================================\n"); + + let generators = plugin::generators(); + println!("Found {} generator(s):\n", generators.len()); + + for generator in generators { + println!(" {} ({}):", + generator.name(), + generator.file_extension() + ); + println!(" {}\n", generator.description()); + } +} diff --git a/mlf-codegen/examples/list_generators.rs b/mlf-codegen/examples/list_generators.rs new file mode 100644 index 0000000..6b63d0b --- /dev/null +++ b/mlf-codegen/examples/list_generators.rs @@ -0,0 +1,31 @@ +// This example demonstrates how to use MLF codegen plugins +// +// To run with all plugins loaded: +// cargo run --example list_generators --features="typescript,go,rust" + +use mlf_codegen::plugin; + +fn main() { + println!("MLF Code Generator Plugins\n"); + println!("===========================\n"); + + let generators = plugin::generators(); + + if generators.is_empty() { + println!("No generators registered!"); + println!("\nTo use plugins, depend on them in your Cargo.toml:"); + println!(" mlf-codegen-typescript = {{ path = \"../codegen-plugins/mlf-codegen-typescript\" }}"); + println!(" mlf-codegen-go = {{ path = \"../codegen-plugins/mlf-codegen-go\" }}"); + println!(" mlf-codegen-rust = {{ path = \"../codegen-plugins/mlf-codegen-rust\" }}"); + } else { + println!("Found {} generator(s):\n", generators.len()); + + for generator in generators { + println!(" {} ({}):", + generator.name(), + generator.file_extension() + ); + println!(" {}\n", generator.description()); + } + } +} diff --git a/mlf-codegen/examples/plugin_test.rs b/mlf-codegen/examples/plugin_test.rs new file mode 100644 index 0000000..de1669f --- /dev/null +++ b/mlf-codegen/examples/plugin_test.rs @@ -0,0 +1,23 @@ +use mlf_codegen::plugin; + +fn main() { + println!("Testing MLF Plugin System\n"); + + // List all registered generators + println!("Registered generators:"); + for generator in plugin::generators() { + println!(" - {} ({}): {}", + generator.name(), + generator.file_extension(), + generator.description() + ); + } + + // Try to find the JSON generator + println!("\nLooking for 'json' generator..."); + if let Some(generator) = plugin::find_generator("json") { + println!("Found: {} - {}", generator.name(), generator.description()); + } else { + println!("Not found!"); + } +} diff --git a/mlf-codegen/src/lib.rs b/mlf-codegen/src/lib.rs index d83da55..5446e8c 100644 --- a/mlf-codegen/src/lib.rs +++ b/mlf-codegen/src/lib.rs @@ -3,6 +3,71 @@ use mlf_lang::Workspace; use serde_json::{json, Map, Value}; use std::collections::HashMap; +// Re-export inventory for macros +#[doc(hidden)] +pub use inventory; + +// Plugin system for code generators +pub mod plugin { + use super::*; + + /// Context passed to code generators + pub struct GeneratorContext<'a> { + pub namespace: &'a str, + pub lexicon: &'a Lexicon, + pub workspace: &'a Workspace, + } + + /// Trait for code generators + pub trait CodeGenerator: Send + Sync { + /// Unique identifier for this generator (e.g., "typescript", "python", "rust") + fn name(&self) -> &'static str; + + /// Human-readable description + fn description(&self) -> &'static str; + + /// File extension for generated files (e.g., ".ts", ".py", ".rs") + fn file_extension(&self) -> &'static str; + + /// Generate code from a lexicon + fn generate(&self, ctx: &GeneratorContext) -> Result; + } + + // Registry of code generators using inventory + inventory::collect!(&'static dyn CodeGenerator); + + /// Get all registered generators as a Vec + pub fn generators() -> Vec<&'static dyn CodeGenerator> { + let mut result = Vec::new(); + for generator in inventory::iter::<&'static dyn CodeGenerator> { + result.push(*generator); + } + result + } + + /// Find a generator by name + pub fn find_generator(name: &str) -> Option<&'static dyn CodeGenerator> { + for generator in inventory::iter::<&'static dyn CodeGenerator> { + if generator.name() == name { + return Some(*generator); + } + } + None + } + + /// Macro to easily register a generator + #[macro_export] + macro_rules! register_generator { + ($generator:expr) => { + $crate::inventory::submit! { + &$generator as &'static dyn $crate::plugin::CodeGenerator + } + }; + } +} + +pub use plugin::{CodeGenerator, GeneratorContext}; + pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspace) -> Value { let usage_counts = analyze_type_usage(lexicon); @@ -563,3 +628,30 @@ fn apply_constraint_to_json(obj: &mut Map, constraint: &Constrain } } } + +// Example built-in generator: JSON Lexicon +pub struct JsonLexiconGenerator; + +impl CodeGenerator for JsonLexiconGenerator { + fn name(&self) -> &'static str { + "json" + } + + fn description(&self) -> &'static str { + "Generate AT Protocol JSON lexicon format" + } + + fn file_extension(&self) -> &'static str { + ".json" + } + + fn generate(&self, ctx: &GeneratorContext) -> Result { + let json = generate_lexicon(ctx.namespace, ctx.lexicon, ctx.workspace); + serde_json::to_string_pretty(&json) + .map_err(|e| format!("Failed to serialize JSON: {}", e)) + } +} + +// Register the JSON generator as a static instance +static JSON_GENERATOR: JsonLexiconGenerator = JsonLexiconGenerator; +register_generator!(JSON_GENERATOR); diff --git a/mlf-wasm/Cargo.toml b/mlf-wasm/Cargo.toml index 661d943..e52d52f 100644 --- a/mlf-wasm/Cargo.toml +++ b/mlf-wasm/Cargo.toml @@ -15,6 +15,7 @@ wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde-wasm-bindgen = "0.6" +web-sys = { version = "0.3", features = ["console"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/mlf-wasm/src/lib.rs b/mlf-wasm/src/lib.rs index 56200be..70f7b7e 100644 --- a/mlf-wasm/src/lib.rs +++ b/mlf-wasm/src/lib.rs @@ -216,6 +216,121 @@ pub fn validate_record(lexicon_source: &str, record_json: &str) -> JsValue { } } +#[derive(Serialize, Deserialize)] +pub struct GeneratorInfo { + pub name: String, + pub description: String, + pub file_extension: String, +} + +#[derive(Serialize, Deserialize)] +pub struct ListGeneratorsResult { + pub generators: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct GenerateCodeResult { + pub success: bool, + pub code: Option, + pub error: Option, +} + +/// List all available code generators +#[wasm_bindgen] +pub fn list_generators() -> JsValue { + let generators = mlf_codegen::plugin::generators(); + + // Debug: log how many generators we found + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("Found {} generators", generators.len()).into()); + + let generator_infos: Vec = generators.iter().map(|generator| { + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!(" - {}", generator.name()).into()); + + GeneratorInfo { + name: generator.name().to_string(), + description: generator.description().to_string(), + file_extension: generator.file_extension().to_string(), + } + }).collect(); + + let result = ListGeneratorsResult { + generators: generator_infos, + }; + + serde_wasm_bindgen::to_value(&result).unwrap() +} + +/// Generate code using a specific generator +#[wasm_bindgen] +pub fn generate_code(source: &str, namespace: &str, generator_name: &str) -> JsValue { + // Load standard library + let mut workspace = match mlf_lang::Workspace::with_std() { + Ok(ws) => ws, + Err(e) => { + let result = GenerateCodeResult { + success: false, + code: None, + error: Some(format!("Failed to load standard library: {:?}", e)), + }; + return serde_wasm_bindgen::to_value(&result).unwrap(); + } + }; + + // Parse the source + let lexicon = match mlf_lang::parse_lexicon(source) { + Ok(lex) => lex, + Err(e) => { + let result = GenerateCodeResult { + success: false, + code: None, + error: Some(format!("Parse error: {:?}", e)), + }; + return serde_wasm_bindgen::to_value(&result).unwrap(); + } + }; + + // Find the generator + let generator = match mlf_codegen::plugin::find_generator(generator_name) { + Some(g) => g, + None => { + let result = GenerateCodeResult { + success: false, + code: None, + error: Some(format!("Generator '{}' not found", generator_name)), + }; + return serde_wasm_bindgen::to_value(&result).unwrap(); + } + }; + + // Generate code + let ctx = mlf_codegen::GeneratorContext { + namespace, + lexicon: &lexicon, + workspace: &workspace, + }; + + match generator.generate(&ctx) { + Ok(code) => { + let result = GenerateCodeResult { + success: true, + code: Some(code), + error: None, + }; + serde_wasm_bindgen::to_value(&result).unwrap() + } + Err(e) => { + let result = GenerateCodeResult { + success: false, + code: None, + error: Some(e), + }; + serde_wasm_bindgen::to_value(&result).unwrap() + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/website/justfile b/website/justfile index c5560fd..40b1c7e 100644 --- a/website/justfile +++ b/website/justfile @@ -1,17 +1,17 @@ # Build development version (faster, no optimizations) build-dev: #!/usr/bin/env bash + cd mlf-playground-wasm + wasm-pack build --target web --out-name mlf_wasm --out-dir ../static/js/pkg --dev cd .. - wasm-pack build mlf-wasm --target web --out-dir ../website/static/js/pkg - cd website zola build # Build release version (optimized) build-release: #!/usr/bin/env bash + cd mlf-playground-wasm + wasm-pack build --target web --out-name mlf_wasm --out-dir ../static/js/pkg --release cd .. - wasm-pack build mlf-wasm --target web --out-dir ../website/static/js/pkg --release - cd website if command -v wasm-opt >/dev/null 2>&1; then wasm-opt -Oz static/js/pkg/mlf_wasm_bg.wasm -o static/js/pkg/mlf_wasm_bg.wasm fi diff --git a/website/mlf-playground-wasm/Cargo.toml b/website/mlf-playground-wasm/Cargo.toml new file mode 100644 index 0000000..3c5f80a --- /dev/null +++ b/website/mlf-playground-wasm/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mlf-playground-wasm" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +mlf-wasm = { path = "../../mlf-wasm" } +mlf-codegen = { path = "../../mlf-codegen" } +mlf-codegen-typescript = { path = "../../codegen-plugins/mlf-codegen-typescript" } +mlf-codegen-go = { path = "../../codegen-plugins/mlf-codegen-go" } +mlf-codegen-rust = { path = "../../codegen-plugins/mlf-codegen-rust" } +wasm-bindgen = "0.2" + +# Force inclusion of plugin crates +[profile.release] +lto = false diff --git a/website/mlf-playground-wasm/src/lib.rs b/website/mlf-playground-wasm/src/lib.rs new file mode 100644 index 0000000..c687c96 --- /dev/null +++ b/website/mlf-playground-wasm/src/lib.rs @@ -0,0 +1,17 @@ +// Re-export everything from mlf-wasm +pub use mlf_wasm::*; + +// Import the plugin crates and reference their static generators +// This forces the linker to include them in the binary +use mlf_codegen_typescript::TYPESCRIPT_GENERATOR; +use mlf_codegen_go::GO_GENERATOR; +use mlf_codegen_rust::RUST_GENERATOR; + +// Force the linker to keep the generator statics by referencing them +// This function must never be optimized away +#[used] +static _KEEP_GENERATORS: &[&dyn mlf_codegen::plugin::CodeGenerator] = &[ + &TYPESCRIPT_GENERATOR, + &GO_GENERATOR, + &RUST_GENERATOR, +]; diff --git a/website/sass/style.scss b/website/sass/style.scss index 47fd654..2b180f6 100644 --- a/website/sass/style.scss +++ b/website/sass/style.scss @@ -474,6 +474,36 @@ body:has(.playground-page) footer { border-color: var(--accent); } +.generator-selector select { + padding: 0.375rem 0.75rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 0.25rem; + color: var(--text-light); + font-size: 0.813rem; + font-family: inherit; + cursor: pointer; + transition: all 0.2s; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23c3c3c3' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + padding-right: 2rem; +} + +.generator-selector select:hover { + color: var(--text); + border-color: var(--text-light); +} + +.generator-selector select:focus { + outline: none; + border-color: var(--accent); + color: var(--text); +} + textarea { width: 100%; flex: 1; diff --git a/website/static/js/app.js b/website/static/js/app.js index 9803f04..d797a01 100644 --- a/website/static/js/app.js +++ b/website/static/js/app.js @@ -182,30 +182,30 @@ function initEditor() { updateLineNumbers(initialCode); updateHighlighting(initialCode); - // Convert JSON output textarea to highlighted div with line numbers - const jsonTextarea = document.getElementById('lexicon-result'); + // Convert generated output textarea to highlighted div with line numbers + const generateTextarea = document.getElementById('generate-result'); - const jsonOuterContainer = document.createElement('div'); - jsonOuterContainer.className = 'shiki-output-outer-container'; - jsonOuterContainer.id = 'lexicon-result-outer-container'; + const generateOuterContainer = document.createElement('div'); + generateOuterContainer.className = 'shiki-output-outer-container'; + generateOuterContainer.id = 'generate-result-outer-container'; - const jsonLineNumbers = document.createElement('div'); - jsonLineNumbers.className = 'line-numbers'; - jsonLineNumbers.id = 'json-line-numbers'; + const generateLineNumbers = document.createElement('div'); + generateLineNumbers.className = 'line-numbers'; + generateLineNumbers.id = 'generate-line-numbers'; - const jsonWrapper = document.createElement('div'); - jsonWrapper.className = 'output-wrapper'; + const generateWrapper = document.createElement('div'); + generateWrapper.className = 'output-wrapper'; - const jsonContainer = document.createElement('div'); - jsonContainer.className = 'shiki-output-container'; - jsonContainer.id = 'lexicon-result-container'; + const generateContainer = document.createElement('div'); + generateContainer.className = 'shiki-output-container'; + generateContainer.id = 'generate-result-container'; - jsonWrapper.appendChild(jsonContainer); - jsonOuterContainer.appendChild(jsonLineNumbers); - jsonOuterContainer.appendChild(jsonWrapper); + generateWrapper.appendChild(generateContainer); + generateOuterContainer.appendChild(generateLineNumbers); + generateOuterContainer.appendChild(generateWrapper); - jsonTextarea.style.display = 'none'; - jsonTextarea.parentNode.insertBefore(jsonOuterContainer, jsonTextarea); + generateTextarea.style.display = 'none'; + generateTextarea.parentNode.insertBefore(generateOuterContainer, generateTextarea); } function updateHighlighting(code) { @@ -299,17 +299,35 @@ function getEditorContent() { return textarea ? textarea.value : ''; } -function updateJsonOutput(jsonString) { - const container = document.getElementById('lexicon-result-container'); +function updateGeneratedOutput(code, generatorType) { + const container = document.getElementById('generate-result-container'); if (!container || !highlighter) return; - // Format JSON - try { - const formatted = JSON.stringify(JSON.parse(jsonString), null, 2); + // Map generator types to Shiki language identifiers + const langMap = { + 'json': 'json', + 'typescript': 'typescript', + 'go': 'go', + 'rust': 'rust' + }; + + const lang = langMap[generatorType] || 'text'; + + // Format JSON if it's JSON output + let formattedCode = code; + if (generatorType === 'json') { + try { + formattedCode = JSON.stringify(JSON.parse(code), null, 2); + } catch (e) { + // If JSON parsing fails, use the original code + formattedCode = code; + } + } + try { // Highlight with Shiki - const html = highlighter.codeToHtml(formatted, { - lang: 'json', + const html = highlighter.codeToHtml(formattedCode, { + lang: lang, theme: 'dracula' }); @@ -321,19 +339,20 @@ function updateJsonOutput(jsonString) { if (codeElement) { container.innerHTML = codeElement.innerHTML; } else { - container.textContent = formatted; + container.textContent = formattedCode; } - // Update line numbers for JSON output - updateJsonLineNumbers(formatted); + // Update line numbers for output + updateGenerateLineNumbers(formattedCode); } catch (e) { - container.textContent = jsonString; - updateJsonLineNumbers(jsonString); + // Fallback if highlighting fails + container.textContent = formattedCode; + updateGenerateLineNumbers(formattedCode); } } -function updateJsonLineNumbers(code) { - const lineNumbers = document.getElementById('json-line-numbers'); +function updateGenerateLineNumbers(code) { + const lineNumbers = document.getElementById('generate-line-numbers'); if (!lineNumbers) return; const lines = code.split('\n').length; @@ -394,12 +413,20 @@ function setupEventListeners() { } } - // Synchronize scroll between JSON output and line numbers - const jsonWrapper = document.querySelector('.output-wrapper'); - const jsonLineNumbers = document.getElementById('json-line-numbers'); - if (jsonWrapper && jsonLineNumbers) { - jsonWrapper.addEventListener('scroll', () => { - jsonLineNumbers.scrollTop = jsonWrapper.scrollTop; + // Synchronize scroll between generated output and line numbers + const generateWrapper = document.querySelector('.output-wrapper'); + const generateLineNumbers = document.getElementById('generate-line-numbers'); + if (generateWrapper && generateLineNumbers) { + generateWrapper.addEventListener('scroll', () => { + generateLineNumbers.scrollTop = generateWrapper.scrollTop; + }); + } + + // Generator dropdown change listener + const generatorSelect = document.getElementById('generator-select'); + if (generatorSelect) { + generatorSelect.addEventListener('change', () => { + handleCheck(); }); } @@ -488,12 +515,17 @@ function handleCheck() { if (checkResult.success) { hideError(); - const generateResult = wasm.generate_lexicon(source, namespace); + // Get selected generator + const generatorSelect = document.getElementById('generator-select'); + const selectedGenerator = generatorSelect ? generatorSelect.value : 'json'; + + // Generate code with selected generator + const generateResult = wasm.generate_code(source, namespace, selectedGenerator); if (generateResult.success) { - updateJsonOutput(generateResult.lexicon); + updateGeneratedOutput(generateResult.code, selectedGenerator); } else { - showError(generateResult.error || 'Failed to generate lexicon'); + showError(generateResult.error || 'Failed to generate code'); } } else { const errors = checkResult.errors || ['Unknown error']; diff --git a/website/templates/playground.html b/website/templates/playground.html index 7bcd92f..a104363 100644 --- a/website/templates/playground.html +++ b/website/templates/playground.html @@ -29,13 +29,21 @@ record thread {
- +
+
+ +
-
- +
+